Files
fusion/packages/dashboard/src/api-error.ts
gsxdsm ab87d0d803 fix(api): return 404 for missing tasks, and make task deletions attributable
Three related fixes, all originating from a `[api:error] Request failed`
log line showing a 500 on `GET /api/tasks/FN-8610/runtime-fallback`.

1. Missing/deleted tasks now return 404 instead of 500.
   `getTaskImpl` signalled a miss with a bare `Error`, and route catches
   only mapped errno `ENOENT` to 404 — a leftover from the file-backed
   storage era. In Postgres mode nothing sets an errno code, so every
   unknown/missing/soft-deleted/wrong-project read returned 500. Adds a
   typed `TaskNotFoundError` (message byte-identical) plus a shared
   `task-lookup-error` mapper applied across the task, session-diff,
   git/GitHub, workflow and file-workspace route registrars. The same
   bare throw existed on both archive-lifecycle delete paths, so
   `DELETE /tasks/:id` was affected too.

2. 5xx logs now carry the origin stack.
   `rethrowAsApiError` constructed a fresh `ApiError` from the message
   and discarded the original, so the `FNXC:ApiErrorDiagnostics`
   contract logged the rethrow site rather than the throw site — the
   reported log entry had no stack at all. Threads `cause` through the
   error factories and walks the chain (bounded, cycle-guarded).

3. Task deletions are attributable, and non-operator deletes notify.
   `task:deleted` audit rows recorded `agentId: "system"` for every HTTP
   delete, making an operator click indistinguishable from a script or
   an agent; the calling agent's task id was accepted by the store and
   then never persisted. Adds a `callerKind` union recorded in audit
   metadata, tags every delete call site, and stamps a self-reported
   `x-fusion-client` header from the dashboard client. When the caller
   is `agent-tool` or `api-unattributed`, a best-effort notice is sent
   to the operator mailbox; operator and engine deletes stay silent.

`x-fusion-client` is attribution, not authentication — anything can send
it. No delete-blocking, gating or permission logic is added here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:15:36 -07:00

214 lines
7.8 KiB
TypeScript

import type { NextFunction, Request, RequestHandler, Response } from "express";
import { createRuntimeLogger, type RuntimeLogger } from "./runtime-logger.js";
export interface ApiErrorResponse {
error: string;
details?: Record<string, unknown>;
}
export interface SendErrorOptions {
details?: Record<string, unknown>;
logger?: RuntimeLogger;
/*
FNXC:ApiErrorDiagnostics 2026-07-10-14:00:
The original thrown error behind a 5xx. When present, its stack (and any `cause`
chain) is logged so server-side 500s are root-causable. Previously only the error
*message* was logged and `rethrowAsApiError` discarded the stack, leaving the
full-TaskDetail 500s on /api/tasks/:id (GET/DELETE/PATCH/retry/archive/reset)
untraceable across releases.
FNXC:ApiErrorDiagnostics 2026-07-26-11:20:
That contract was silently defeated on the most common path: `rethrowAsApiError`
built a fresh `ApiError(500, error.message)` and dropped the caught error, so the
boundary logged the rethrow site's stack (or, for a non-Error throw, no stack at
all — observed on GET /api/tasks/FN-8610/runtime-fallback, whose 500 log carried
only method/path/statusCode/message). The caught value is now threaded into
`ApiError.cause` end-to-end, and the boundary walks the chain so `stack` is the
ORIGIN error's stack and `cause` renders the wrappers between origin and boundary.
*/
error?: unknown;
}
/*
FNXC:ApiErrorDiagnostics 2026-07-26-11:20:
Chain-walk bounds. Depth caps a pathological/self-referential wrap; the visited set
stops a cycle (`a.cause = b; b.cause = a`) from spinning the request thread.
*/
const MAX_CAUSE_DEPTH = 8;
/**
* FNXC:ApiErrorDiagnostics 2026-07-26-11:20:
* Flatten an error and its `cause` chain, nearest-wrapper first. Values are returned
* as-is (not stringified) so callers decide the log rendering; only Errors and raw
* thrown values enter the chain — never arbitrary object payloads.
*/
function collectCauseChain(error: unknown): unknown[] {
const chain: unknown[] = [];
const seen = new Set<unknown>();
let current = error;
while (current !== undefined && chain.length < MAX_CAUSE_DEPTH) {
if (typeof current === "object" && current !== null) {
if (seen.has(current)) break;
seen.add(current);
}
chain.push(current);
current = current instanceof Error ? (current as { cause?: unknown }).cause : undefined;
}
return chain;
}
/**
* FNXC:ApiErrorDiagnostics 2026-07-26-11:20:
* Render one chain link for the log: an Error contributes its stack (message fallback
* when a runtime omits `stack`), any other thrown value its `String(...)` form. Keeps
* the log context ids/paths/stack-only.
*/
function renderChainLink(value: unknown): string {
if (value instanceof Error) return value.stack ?? value.message;
return String(value);
}
export class ApiError extends Error {
public readonly statusCode: number;
public readonly details?: Record<string, unknown>;
public readonly isOperational: boolean;
constructor(statusCode: number, message: string, details?: Record<string, unknown>, cause?: unknown) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.details = details;
this.isOperational = true;
// FNXC:ApiErrorDiagnostics 2026-07-10-14:00: preserve the wrapped error's
// stack/chain via Error `cause` so the boundary can log where the 500 came
// from (assigned directly rather than via super(message,{cause}) to stay
// independent of the compiled lib target).
if (cause !== undefined) {
(this as { cause?: unknown }).cause = cause;
}
}
}
export function sendErrorResponse(
res: Response,
statusCode: number,
message: string,
options?: SendErrorOptions,
): Response<ApiErrorResponse> {
if (statusCode >= 500) {
const request = res.req;
const logger = options?.logger ?? createRuntimeLogger("api:error");
// FNXC:ApiErrorDiagnostics 2026-07-10-14:00: log the underlying stack and
// cause (not just the message) so a 500 can be traced to its origin.
/*
FNXC:ApiErrorDiagnostics 2026-07-26-11:20:
Walk the whole `cause` chain rather than one level. `stack` reports the DEEPEST
Error in the chain — the origin throw site — because the boundary is handed the
wrapping `ApiError` whose own stack only names `rethrowAsApiError`. `cause`
reports every link past the boundary error so intermediate wrappers survive. An
unwrapped Error still logs its own stack and no cause, unchanged from before.
*/
const originalError = options?.error;
const chain = collectCauseChain(originalError);
const errorLinks = chain.filter((link): link is Error => link instanceof Error);
const originError = errorLinks.length > 0 ? errorLinks[errorLinks.length - 1] : undefined;
const causeLinks = chain.slice(1);
logger.error("Request failed", {
method: request?.method,
path: request?.originalUrl ?? request?.path,
statusCode,
message,
stack: originError?.stack,
cause: causeLinks.length > 0 ? causeLinks.map(renderChainLink).join("\nCaused by: ") : undefined,
});
}
const payload: ApiErrorResponse = { error: message };
if (options?.details !== undefined) {
payload.details = options.details;
}
return res.status(statusCode).json(payload);
}
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown> | unknown;
export function catchHandler(fn: AsyncHandler): RequestHandler {
return async (req, res, next) => {
try {
await fn(req, res, next);
} catch (error) {
if (res.headersSent) {
next(error);
return;
}
if (error instanceof ApiError) {
sendErrorResponse(res, error.statusCode, error.message, { details: error.details, error });
return;
}
if (error instanceof Error) {
sendErrorResponse(res, 500, error.message, { error });
return;
}
sendErrorResponse(res, 500, "Internal server error", { error });
}
};
}
/*
FNXC:ApiErrorDiagnostics 2026-07-26-11:20:
Every factory takes an optional TRAILING `cause` so the hundreds of existing
single/double-argument call sites across the route registrars stay source-compatible;
callers that already hold the caught error can pass it and get an origin stack in the
5xx log for free.
*/
export function badRequest(message: string, details?: Record<string, unknown>, cause?: unknown): ApiError {
return new ApiError(400, message, details, cause);
}
export function unauthorized(message: string, cause?: unknown): ApiError {
return new ApiError(401, message, undefined, cause);
}
export function notFound(message: string, cause?: unknown): ApiError {
return new ApiError(404, message, undefined, cause);
}
export function conflict(message: string, details?: Record<string, unknown>, cause?: unknown): ApiError {
return new ApiError(409, message, details, cause);
}
export function rateLimited(message: string, retryAfter?: number, cause?: unknown): ApiError {
return new ApiError(429, message, { retryAfter }, cause);
}
export function internalError(message: string, cause?: unknown): ApiError {
return new ApiError(500, message, undefined, cause);
}
/**
* FNXC:ApiErrorDiagnostics 2026-07-26-11:20:
* Thread the caught value into the wrapping `ApiError` on BOTH branches. Before this
* the caught error was read for its message and then discarded, so the 5xx boundary
* logged the stack of the `ApiError` constructed here (or nothing at all for a
* non-Error throw). Message, status, and response body are unchanged — this only
* adds `cause`.
*/
export function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
if (error instanceof ApiError) {
throw error;
}
if (error instanceof Error && error.message) {
throw internalError(error.message, error);
}
throw internalError(fallbackMessage, error);
}