Skip to content
Open
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
197 changes: 97 additions & 100 deletions backend/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { requestLogger } from "./middleware/requestLogger";
import { errorHandler } from "./middleware/errorHandler";
import { createLogger } from "../utils/logger";
import { createTaskDb, getTaskDb } from "../db/tasks";
import { ValidationError, UnauthorizedError, NotFoundError, AppError } from "../errors";

export interface AppOptions {
/** Called to execute a single DAG node; defaults to HTTP dispatch */
Expand All @@ -37,12 +38,6 @@ export interface AppOptions {
stream?: TaskStreamOptions;
}

/**
* Attempt to load smart-contracts releasePayment at runtime via dynamic require.
* Returns undefined when the module is unavailable (e.g. backend CI without
* smart-contracts compiled). Using require() instead of a static import keeps
* TypeScript's rootDir constraint intact.
*/
function tryLoadStellarRelease(): StellarReleasePaymentFn | undefined {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
Expand All @@ -59,132 +54,133 @@ export function createApp(opts: AppOptions = {}): {
} {
const app = express();
app.use(express.json());
// ── Global middleware ────────────────────────────────────────────────────────
app.use(requestId);
app.use(requestLogger);

const dispatch: DispatchFn = opts.dispatch ?? defaultDispatch;
const releasePayment: PaymentReleaseFn =
opts.releasePayment ?? createPaymentReleaseFn(tryLoadStellarRelease());

// ── Health routes ───────────────────────────────────────────────────────────
app.use("/health", healthRouter);

// ── Agent routes ───────────────────────────────────────────────────────────
app.use("/api/agents", agentsRouter);

// ── POST /api/tasks ────────────────────────────────────────────────────────
app.post(
"/api/tasks",
authMiddleware,
rateLimitMiddleware,
(req: Request, res: Response) => {
const { prompt, walletPublicKey, maxBudgetXLM } = req.body as {
prompt?: string;
walletPublicKey?: string;
maxBudgetXLM?: number;
};

if (!prompt || typeof prompt !== "string" || prompt.trim() === "") {
return res.status(400).json({ error: "prompt is required" });
}

if (maxBudgetXLM !== undefined && maxBudgetXLM < 0.1) {
return res.status(400).json({ error: "maxBudgetXLM must be >= 0.1" });
}

const taskId = `task_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
const dag = decompose(taskId, prompt);
const now = new Date().toISOString();
const correlationId = res.locals.requestId;

createTask({
taskId,
prompt,
walletPublicKey:
walletPublicKey ??
(req.headers["walletpublickey"] as string | undefined) ??
"anonymous",
status: "queued",
dag,
createdAt: now,
updatedAt: now,
requestId: correlationId,
});
(req: Request, res: Response, next: NextFunction) => {
try {
const { prompt, walletPublicKey, maxBudgetXLM } = req.body as {
prompt?: string;
walletPublicKey?: string;
maxBudgetXLM?: number;
};

if (!prompt || typeof prompt !== "string" || prompt.trim() === "") {
throw new ValidationError("prompt is required");
}

if (maxBudgetXLM !== undefined && maxBudgetXLM < 0.1) {
throw new ValidationError("maxBudgetXLM must be >= 0.1");
}

const taskId = `task_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
const dag = decompose(taskId, prompt);
const now = new Date().toISOString();
const correlationId = res.locals.requestId;

createTask({
taskId,
prompt,
walletPublicKey:
walletPublicKey ??
(req.headers["walletpublickey"] as string | undefined) ??
"anonymous",
status: "queued",
dag,
createdAt: now,
updatedAt: now,
requestId: correlationId,
});

const log = createLogger({ requestId: correlationId, taskId });
const log = createLogger({ requestId: correlationId, taskId });

// Run the DAG asynchronously β€” do not await
setImmediate(() => {
executeDAG(getTask(taskId)!, dispatch, releasePayment).catch((err) => {
log.error({ err }, "DAG execution error");
setImmediate(() => {
executeDAG(getTask(taskId)!, dispatch, releasePayment).catch((err) => {
log.error({ err }, "DAG execution error");
});
});
});

log.info({ dagNodeCount: dag.length }, "task created");
log.info({ dagNodeCount: dag.length }, "task created");

return res
.status(201)
.json({ taskId, dagPreview: dag, status: "queued" });
return res
.status(201)
.json({ taskId, dagPreview: dag, status: "queued" });
} catch (err) {
next(err);
}
},
);

// ── GET /api/tasks ─────────────────────────────────────────────────────────
app.get("/api/tasks", authMiddleware, (req: Request, res: Response) => {
const walletPublicKey = req.headers["walletpublickey"] as
string | undefined;
if (!walletPublicKey)
return res.status(401).json({ error: "walletpublickey header required" });
const page = Math.max(1, parseInt((req.query.page as string) ?? "1", 10));
const pageSize = Math.min(
100,
Math.max(1, parseInt((req.query.pageSize as string) ?? "20", 10)),
);
const taskDb = createTaskDb(getTaskDb());
const status = req.query.status as string | undefined;
const q = req.query.q as string | undefined;
const sort = req.query.sort as
"createdAt:asc" | "createdAt:desc" | undefined;
const { tasks, total } = taskDb.list(walletPublicKey, page, pageSize, {
status,
q,
sort,
});
return res.json({ tasks, total, page, pageSize });
app.get("/api/tasks", authMiddleware, (req: Request, res: Response, next: NextFunction) => {
try {
const walletPublicKey = req.headers["walletpublickey"] as string | undefined;
if (!walletPublicKey) {
throw new UnauthorizedError("walletpublickey header required");
}
const page = Math.max(1, parseInt((req.query.page as string) ?? "1", 10));
const pageSize = Math.min(
100,
Math.max(1, parseInt((req.query.pageSize as string) ?? "20", 10)),
);
const taskDb = createTaskDb(getTaskDb());
const status = req.query.status as string | undefined;
const q = req.query.q as string | undefined;
const sort = req.query.sort as
"createdAt:asc" | "createdAt:desc" | undefined;
const { tasks, total } = taskDb.list(walletPublicKey, page, pageSize, {
status,
q,
sort,
});
return res.json({ tasks, total, page, pageSize });
} catch (err) {
next(err);
}
});

// ── GET /api/tasks/:id ─────────────────────────────────────────────────────
app.get("/api/tasks/:id", (req: Request, res: Response) => {
const task = getTask(req.params.id!);
if (!task) return res.status(404).json({ error: "Task not found" });
return res.json({ ...task, id: task.taskId, dag: task.dag });
app.get("/api/tasks/:id", (req: Request, res: Response, next: NextFunction) => {
try {
const task = getTask(req.params.id!);
if (!task) throw new NotFoundError("Task");
return res.json({ ...task, id: task.taskId, dag: task.dag });
} catch (err) {
next(err);
}
});

// ── DELETE /api/tasks/:id ──────────────────────────────────────────────────
app.delete("/api/tasks/:id", (req: Request, res: Response) => {
const task = getTask(req.params.id!);
if (!task) return res.status(404).json({ error: "Task not found" });
if (task.status === "running") {
return res.status(409).json({ error: "Cannot cancel a running task" });
app.delete("/api/tasks/:id", (req: Request, res: Response, next: NextFunction) => {
try {
const task = getTask(req.params.id!);
if (!task) throw new NotFoundError("Task");
if (task.status === "running") {
throw new AppError("Cannot cancel a running task", 409, "CONFLICT");
}
const taskDb = createTaskDb(getTaskDb());
taskDb.updateStatus(req.params.id!, "cancelled");
return res.json({ ...task, id: task.taskId, status: "cancelled" });
} catch (err) {
next(err);
}
const taskDb = createTaskDb(getTaskDb());
taskDb.updateStatus(req.params.id!, "cancelled");
return res.json({ ...task, id: task.taskId, status: "cancelled" });
});

// ── HTTP server ────────────────────────────────────────────────────────────
const httpServer = createServer(app);

// ── Event persistence ──────────────────────────────────────────────────────
// Record every Coordinator event (with its EventBus-assigned per-task seq) so
// a (re)connecting client can replay history before live streaming begins β€”
// either the full history, or only events past a `?lastEventId` cursor.
const eventStore = opts.eventStore ?? createEventStore();
const stopRecording = eventBus.subscribeAll((event) =>
eventStore.append(event),
);

// ── WebSocket: /tasks/:id/stream ───────────────────────────────────────────
const detachStream = attachTaskStream({
httpServer,
eventStore,
Expand All @@ -193,7 +189,6 @@ export function createApp(opts: AppOptions = {}): {
...opts.stream,
});

// ── Error handler (must be last) ───────────────────────────────────────────
app.use(errorHandler);

function close(): void {
Expand All @@ -211,7 +206,9 @@ async function defaultDispatch(
node: { nodeId: string; agentType: string; prompt: string },
context: string,
): Promise<unknown> {
// In production this POSTs to the agent's HTTP endpoint.
// The e2e test replaces this via opts.dispatch.
throw new Error(`No agent registered for type: ${node.agentType}`);
throw new AppError(
`No agent registered for type: ${node.agentType}`,
500,
"AGENT_NOT_FOUND",
);
}
4 changes: 2 additions & 2 deletions backend/src/api/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Request, Response, NextFunction } from 'express';
import { UnauthorizedError } from '../../errors';

function loadKeys(): Set<string> | null {
const raw = process.env.API_KEYS;
Expand All @@ -10,15 +11,14 @@ function loadKeys(): Set<string> | null {
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const keys = loadKeys();
if (!keys) {
// API_KEYS unset β€” no-op, backward compatible
next();
return;
}

const auth = req.headers['authorization'] ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
if (!token || !keys.has(token)) {
res.status(401).json({ error: 'Unauthorized' });
next(new UnauthorizedError());
return;
}

Expand Down
45 changes: 44 additions & 1 deletion backend/src/api/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,52 @@
import type { Request, Response, NextFunction } from "express";
import { createLogger } from "../../utils/logger";
import { AppError } from "../../errors";

const log = createLogger();
const isProduction = process.env.NODE_ENV === "production";

export function errorHandler(err: Error, req: Request, res: Response, _next: NextFunction): void {
if (err instanceof AppError) {
if (!err.isOperational) {
log.error(
{
error: err.message,
code: err.code,
stack: err.stack,
method: req.method,
path: req.path,
requestId: res.locals.requestId,
},
"non-operational error",
);
} else {
log.warn(
{
error: err.message,
code: err.code,
method: req.method,
path: req.path,
requestId: res.locals.requestId,
},
"operational error",
);
}

const body: { error: { code: string; message: string; details?: unknown } } = {
error: {
code: err.code,
message: err.message,
},
};

if (err.details !== undefined) {
body.error.details = err.details;
}

res.status(err.statusCode).json(body);
return;
}

log.error(
{
error: err.message,
Expand All @@ -17,7 +60,7 @@ export function errorHandler(err: Error, req: Request, res: Response, _next: Nex

res.status(500).json({
error: {
message: err.message || "Internal server error",
message: isProduction ? "Internal server error" : err.message || "Internal server error",
code: "INTERNAL_ERROR",
},
});
Expand Down
Loading
Loading