feat(server,web): import and export Trace files in the trace viewer (#73)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-27 22:54:57 +08:00
committed by GitHub
parent d29d2ba7f3
commit f192db338e
13 changed files with 592 additions and 8 deletions
@@ -1,17 +1,32 @@
/**
* Agent-level Trace browsing routes:
* - GET /api/projects/:p/agents/:a/traces — drills down Agent -> date -> Session -> index (reverse order);
* - GET /api/projects/:p/agents/:a/traces/:sessionId/:index (including /analysis) —
* - GET /api/projects/:p/agents/:a/traces/:sessionId/:index (including /analysis, /download) —
* read-only Trace detail endpoints (FD-3): locate the Trace file directly by
* (projectId, agentId, sessionId), without depending on the sessions table for
* tracking — any entry visible in the directory tree (subagent child Sessions,
* CLI-created Sessions) can be opened and read; access is enforced by requireProjectAccess.
* - POST /api/projects/:p/agents/:a/traces/import — uploads a Trace JSONL file (owner
* only, mirroring the Agent snapshot import); the file names itself via its
* session_meta and always becomes index 001 of a new Session — a session id the
* Agent already has is rejected with 409 trace_session_exists.
*/
import { Hono } from "hono";
import type { AppEnv } from "../../auth/middleware.js";
import { paginationQuery, positiveIntParam, requireValidId } from "../validate.js";
import type { TraceImportResponse } from "../../api/types.js";
import {
badRequest,
paginationQuery,
positiveIntParam,
readJson,
requireString,
requireValidId,
} from "../validate.js";
import type { AppDeps } from "../../app.js";
/** Import file size cap: aligned with the snapshot import (stays within the 20MB body limit after base64). */
const MAX_TRACE_BYTES = 14 * 1024 * 1024;
export function agentTracesRoutes(deps: AppDeps): Hono<AppEnv> {
const app = new Hono<AppEnv>();
@@ -44,5 +59,47 @@ export function agentTracesRoutes(deps: AppDeps): Hono<AppEnv> {
return c.json(await deps.traceService.analyze(projectId, agentId, sessionId, index));
});
// Raw-file download (any member, like the snapshot export): the file is served verbatim
// as an attachment, so what's downloaded can be re-imported byte-compatibly.
app.get("/:sessionId/:index/download", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
const sessionId = requireValidId(c, "sessionId");
deps.projectService.requireProjectAccess(c.var.user.userId, projectId);
const index = positiveIntParam(c, "index");
const bytes = await deps.traceService.readFileRaw(projectId, agentId, sessionId, index);
const fileName = `${sessionId}_${String(index).padStart(3, "0")}.jsonl`;
return new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": "application/x-ndjson",
"Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
"X-Content-Type-Options": "nosniff",
},
});
});
// Trace file upload (owner only, mirroring the Agent snapshot import): the route checks the
// transport shape (base64, size); the content itself — JSONL, leading session_meta, a
// filename-safe session_id — is validated by the service right where the path is built.
app.post("/import", async (c) => {
const projectId = requireValidId(c, "projectId");
const agentId = requireValidId(c, "agentId");
deps.projectService.requireProjectOwner(c.var.user.userId, projectId);
await deps.agentConfigService.requireExists(projectId, agentId);
const body = await readJson(c);
const dataBase64 = requireString(body, "dataBase64", { minLen: 1, maxLen: 20 * 1024 * 1024 });
const bytes = Buffer.from(dataBase64, "base64");
if (bytes.byteLength === 0) throw badRequest("Import file is empty.");
if (bytes.byteLength > MAX_TRACE_BYTES) {
throw badRequest("Import file exceeds the 14MB limit.");
}
const res: TraceImportResponse = await deps.traceService.importTraceFile(
projectId,
agentId,
bytes.toString("utf8"),
);
return c.json(res);
});
return app;
}