feat: initial commit

This commit is contained in:
Jeffrey Wu
2026-09-07 09:57:33 +08:00
commit f39f6ac881
66 changed files with 9859 additions and 0 deletions
+123
View File
@@ -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 } }); }
+12
View File
@@ -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());