feat: initial commit
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import multipart from "@fastify/multipart";
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import { ZodError, z } from "zod";
|
||||
import {
|
||||
FileAttachmentStore,
|
||||
EncryptedFileCredentialVault,
|
||||
HarnessError,
|
||||
InMemoryEventStore,
|
||||
InMemoryModelConnectionStore,
|
||||
InMemorySessionRegistry,
|
||||
PiAgentSessionFactory,
|
||||
SkillCatalog,
|
||||
safeErrorMessage,
|
||||
type AgentSessionFactory,
|
||||
} from "@agent-studio/harness";
|
||||
import { modelConnectionInputSchema, sendMessageSchema } from "@agent-studio/shared";
|
||||
|
||||
const idParams = z.object({ sessionId: z.string().min(1) });
|
||||
const attachmentParams = idParams.extend({ attachmentId: z.string().min(1) });
|
||||
const approvalParams = z.object({ approvalId: z.string().min(1) });
|
||||
const eventQuery = z.object({ afterSequence: z.coerce.number().int().nonnegative().optional() });
|
||||
|
||||
function parse<T>(schema: z.ZodType<T>, value: unknown): T { return schema.parse(value); }
|
||||
|
||||
export interface AppServices { sessions: InMemorySessionRegistry; events: InMemoryEventStore; attachments: FileAttachmentStore; models: InMemoryModelConnectionStore; skills: SkillCatalog }
|
||||
export interface AppOptions { projectRoot?: string; runTimeoutMs?: number; approvalTimeoutMs?: number; heartbeatMs?: number; agentFactory?: AgentSessionFactory; onReady?: (services: AppServices) => void }
|
||||
|
||||
export async function buildApp(options: AppOptions = {}): Promise<FastifyInstance> {
|
||||
const projectRoot = options.projectRoot ?? path.resolve(import.meta.dirname, "../../..");
|
||||
const sessionsRoot = path.join(projectRoot, "workspace", "sessions");
|
||||
const agentDir = path.join(projectRoot, "workspace", ".pi-catalog");
|
||||
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
|
||||
await mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
const events = new InMemoryEventStore(1_000);
|
||||
const credentialVault = process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY
|
||||
? new EncryptedFileCredentialVault(path.join(projectRoot, "workspace", "model-credentials.enc.json"), process.env.MODEL_CREDENTIAL_ENCRYPTION_KEY)
|
||||
: undefined;
|
||||
const models = new InMemoryModelConnectionStore(process.env, credentialVault);
|
||||
// Validate and cache the .env default before any Web Session is accepted.
|
||||
await models.initialize();
|
||||
const attachments = new FileAttachmentStore(sessionsRoot, Number(process.env.MAX_IMAGE_BYTES ?? 5 * 1024 * 1024));
|
||||
const skills = new SkillCatalog(projectRoot, agentDir);
|
||||
await skills.reload();
|
||||
const sessions = new InMemorySessionRegistry(sessionsRoot, events, attachments, models, skills, undefined, options.approvalTimeoutMs ?? Number(process.env.APPROVAL_TIMEOUT_MS ?? 60_000), options.runTimeoutMs ?? Number(process.env.SESSION_RUN_TIMEOUT_MS ?? 120_000));
|
||||
sessions.setFactory(options.agentFactory ?? new PiAgentSessionFactory(path.join(projectRoot, "fixtures", "ads-account.json"), skills, models, sessions.approvals));
|
||||
options.onReady?.({ sessions, events, attachments, models, skills });
|
||||
|
||||
const app = Fastify({
|
||||
genReqId: () => `req_${randomUUID()}`,
|
||||
logger: process.env.NODE_ENV === "test" ? false : { level: process.env.LOG_LEVEL ?? "info", redact: { paths: ["req.headers.authorization", "req.body.apiKey", "*.apiKey", "*.authorization"], censor: "[REDACTED]" } },
|
||||
bodyLimit: 1_000_000,
|
||||
});
|
||||
await app.register(multipart, { limits: { files: 1, fileSize: Number(process.env.MAX_IMAGE_BYTES ?? 5 * 1024 * 1024) + 1, fields: 4 } });
|
||||
app.addHook("onRequest", async (request, reply) => { reply.header("x-request-id", request.id); });
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof ZodError) return sendError(reply, request, 400, "VALIDATION_ERROR", "Request validation failed", { fields: z.flattenError(error).fieldErrors });
|
||||
if (error instanceof HarnessError) return sendError(reply, request, error.statusCode, error.code, error.message, error.details);
|
||||
if ((error as { code?: string }).code === "FST_REQ_FILE_TOO_LARGE") return sendError(reply, request, 413, "ATTACHMENT_TOO_LARGE", "Image exceeds the configured size limit");
|
||||
request.log.error({ requestId: request.id, code: "INTERNAL_ERROR", message: safeErrorMessage(error) }, "request failed");
|
||||
return sendError(reply, request, 500, "INTERNAL_ERROR", "Internal server error");
|
||||
});
|
||||
|
||||
app.get("/health", async (request) => ({ ok: true, requestId: request.id }));
|
||||
app.get("/api/skills", async (request) => ({ skills: skills.list(), requestId: request.id }));
|
||||
app.post("/api/sessions", async (request, reply) => { const session = await sessions.create(); return reply.code(201).send({ sessionId: session.sessionId, requestId: request.id }); });
|
||||
app.delete("/api/sessions/:sessionId", async (request, reply) => { const { sessionId } = parse(idParams, request.params); await sessions.delete(sessionId); return reply.send({ deleted: true, requestId: request.id }); });
|
||||
app.post("/api/sessions/:sessionId/messages", async (request, reply) => { const { sessionId } = parse(idParams, request.params); const body = parse(sendMessageSchema, request.body); const result = await sessions.send(sessionId, body); return reply.code(202).send({ ...result, requestId: request.id }); });
|
||||
app.post("/api/sessions/:sessionId/cancel", async (request, reply) => { const { sessionId } = parse(idParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); await sessions.cancel(sessionId); return reply.send({ cancelled: true, requestId: request.id }); });
|
||||
|
||||
app.get("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = parse(idParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); return { ...models.getRedacted(sessionId), requestId: request.id }; });
|
||||
app.put("/api/sessions/:sessionId/model-config", async (request) => { const { sessionId } = parse(idParams, request.params); const input = parse(modelConnectionInputSchema, request.body); return { ...(await sessions.configureModel(sessionId, input)), requestId: request.id }; });
|
||||
app.delete("/api/sessions/:sessionId/model-config/credentials", async (request) => { const { sessionId } = parse(idParams, request.params); await sessions.clearCredentials(sessionId); return { cleared: true, requestId: request.id }; });
|
||||
app.post("/api/sessions/:sessionId/model-config/test", async (request) => { const { sessionId } = parse(idParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); const config = models.getRedacted(sessionId); if (!config.configured) throw new HarnessError("MODEL_NOT_CONFIGURED", "No model connection is configured", 422); return { status: "deferred", message: "配置已通过本地校验;为避免记录模型正文,将在首次运行时验证连接。", requestId: request.id }; });
|
||||
|
||||
app.post("/api/sessions/:sessionId/attachments", async (request, reply) => {
|
||||
const { sessionId } = parse(idParams, request.params);
|
||||
if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404);
|
||||
const file = await request.file();
|
||||
if (!file) throw new HarnessError("ATTACHMENT_REQUIRED", "Multipart image file is required", 400);
|
||||
try {
|
||||
const item = await attachments.add(sessionId, { filename: file.filename, mimetype: file.mimetype, stream: file.file });
|
||||
events.append(sessionId, { type: "attachment.uploaded", payload: { attachmentId: item.attachmentId, name: item.name, mediaType: item.mediaType, byteSize: item.byteSize, width: item.width, height: item.height, status: "ready" } });
|
||||
return reply.code(201).send({ ...item, requestId: request.id });
|
||||
} catch (error) {
|
||||
events.append(sessionId, { type: "attachment.rejected", payload: { name: file.filename.slice(0, 200), code: error instanceof HarnessError ? error.code : "ATTACHMENT_REJECTED", message: error instanceof HarnessError ? error.message : "Attachment rejected" } });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
app.delete("/api/sessions/:sessionId/attachments/:attachmentId", async (request) => { const { sessionId, attachmentId } = parse(attachmentParams, request.params); if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404); await attachments.remove(sessionId, attachmentId); return { deleted: true, requestId: request.id }; });
|
||||
|
||||
app.post("/api/approvals/:approvalId/approve", async (request) => { const { approvalId } = parse(approvalParams, request.params); const result = sessions.approvals.approve(approvalId); return { approval: result.request, requestId: request.id }; });
|
||||
app.post("/api/approvals/:approvalId/deny", async (request) => { const { approvalId } = parse(approvalParams, request.params); const result = sessions.approvals.deny(approvalId); return { approval: result.request, requestId: request.id }; });
|
||||
|
||||
app.get("/api/sessions/:sessionId/events", async (request, reply) => {
|
||||
const { sessionId } = parse(idParams, request.params);
|
||||
if (!sessions.get(sessionId)) throw new HarnessError("SESSION_NOT_FOUND", "Session not found", 404);
|
||||
const query = parse(eventQuery, request.query);
|
||||
const lastEventId = request.headers["last-event-id"];
|
||||
const cursor = typeof lastEventId === "string" ? { eventId: lastEventId } : query.afterSequence !== undefined ? { sequence: query.afterSequence } : {};
|
||||
reply.hijack();
|
||||
const raw = reply.raw;
|
||||
raw.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no", "x-request-id": request.id });
|
||||
const write = (chunk: string) => { if (raw.writableLength > 1024 * 1024) { raw.destroy(); return; } raw.write(chunk); };
|
||||
const batch = events.listAfter(sessionId, cursor);
|
||||
if (batch.gap) write(`event: stream.gap\ndata: ${JSON.stringify({ type: "stream.gap", ...batch.gap })}\n\n`);
|
||||
for (const item of batch.events) write(serializeEvent(item));
|
||||
const unsubscribe = events.subscribe(sessionId, (item) => write(serializeEvent(item)));
|
||||
const heartbeat = setInterval(() => write(`: heartbeat ${Date.now()}\n\n`), options.heartbeatMs ?? 15_000);
|
||||
const cleanup = () => { clearInterval(heartbeat); unsubscribe(); };
|
||||
request.raw.once("close", cleanup);
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => { await sessions.shutdown(); });
|
||||
return app;
|
||||
}
|
||||
|
||||
function serializeEvent(event: { eventId: string; type: string } & Record<string, unknown>): string { return `id: ${event.eventId}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`; }
|
||||
function sendError(reply: FastifyReply, request: FastifyRequest, status: number, code: string, message: string, details: Record<string, unknown> = {}) { return reply.code(status).send({ error: { code, message, requestId: request.id, details } }); }
|
||||
@@ -0,0 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { loadEnvFile } from "node:process";
|
||||
import { buildApp } from "./app.js";
|
||||
|
||||
try { loadEnvFile(path.resolve(import.meta.dirname, "../../../.env")); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
||||
const app = await buildApp();
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
await app.listen({ host: "127.0.0.1", port });
|
||||
|
||||
const shutdown = async () => { await app.close(); process.exit(0); };
|
||||
process.once("SIGINT", () => void shutdown());
|
||||
process.once("SIGTERM", () => void shutdown());
|
||||
Reference in New Issue
Block a user