From f192db338e999dcb6c3156b4f64db64bebad296d Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Mon, 27 Jul 2026 22:54:57 +0800 Subject: [PATCH] feat(server,web): import and export Trace files in the trace viewer (#73) Co-authored-by: Claude Fable 5 --- packages/docs/content/server-api.en.md | 4 + packages/docs/content/server-api.zh.md | 4 + .../docs/content/sessions-and-traces.en.md | 2 +- .../docs/content/sessions-and-traces.zh.md | 2 +- packages/server/src/api/types.ts | 14 ++ .../server/src/http/routes/agent-traces.ts | 61 ++++- packages/server/src/services/trace-service.ts | 97 +++++++- .../server/test/trace-import-export.test.ts | 231 ++++++++++++++++++ packages/web/src/api/endpoints.ts | 19 ++ packages/web/src/components/ui/icons.tsx | 44 ++++ .../web/src/features/traces/traces-page.tsx | 107 +++++++- packages/web/src/lib/strings-en.ts | 8 + packages/web/src/lib/strings.ts | 7 + 13 files changed, 592 insertions(+), 8 deletions(-) create mode 100644 packages/server/test/trace-import-export.test.ts diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md index 3fd5254..673577c 100644 --- a/packages/docs/content/server-api.en.md +++ b/packages/docs/content/server-api.en.md @@ -134,6 +134,10 @@ On Session creation, `modelId` and `provider` are both-or-neither: send the comp | GET | /agents/:agentId/traces | Date → Session drill-down structure of Trace files | | GET | /agents/:agentId/traces/:sessionId/:index | Read Trace events (`offset` / `limit` pagination) | | GET | /agents/:agentId/traces/:sessionId/:index/analysis | Trace performance analysis | +| GET | /agents/:agentId/traces/:sessionId/:index/download | Download the raw Trace file (JSONL attachment) | +| POST | /agents/:agentId/traces/import | Import a Trace file: `{dataBase64}` → `{sessionId, index, date}` | + +Trace download is available to any member; import is owner-only (like the Agent snapshot import, capped at 14MB). An imported file must be valid Trace JSONL whose first record is a `session_meta` with a filename-safe `session_id`; a session id the Agent already has is rejected (409 `trace_session_exists`), so an imported file always becomes index 001 of a new Session, landing in the local date directory of its first record's timestamp. ### Session-Level Endpoints diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md index 8fb8ffa..a631a5e 100644 --- a/packages/docs/content/server-api.zh.md +++ b/packages/docs/content/server-api.zh.md @@ -134,6 +134,10 @@ Schedule 写操作仅限 Owner。新建 Session 模式的任务,`modelId` 与 | GET | /agents/:agentId/traces | Trace 文件的日期 → Session 下钻结构 | | GET | /agents/:agentId/traces/:sessionId/:index | 读取 Trace 事件(`offset` / `limit` 分页) | | GET | /agents/:agentId/traces/:sessionId/:index/analysis | Trace 性能分析结果 | +| GET | /agents/:agentId/traces/:sessionId/:index/download | 下载 Trace 原始文件(JSONL 附件) | +| POST | /agents/:agentId/traces/import | 导入 Trace 文件:`{dataBase64}` → `{sessionId, index, date}` | + +Trace 下载对任意成员开放;导入仅限 owner(同 Agent 快照导入,上限 14MB)。导入文件必须是合法的 Trace JSONL,且首条记录为携带文件名安全 `session_id` 的 `session_meta`;若该 Agent 已存在同名 Session,导入将被拒绝(409 `trace_session_exists`),因此导入文件总是成为一个新 Session 的 001 号文件,并按首条记录时间戳的本地日期落入对应日期目录。 ### Session 级接口 diff --git a/packages/docs/content/sessions-and-traces.en.md b/packages/docs/content/sessions-and-traces.en.md index 032286c..1daf5e6 100644 --- a/packages/docs/content/sessions-and-traces.en.md +++ b/packages/docs/content/sessions-and-traces.en.md @@ -89,4 +89,4 @@ Each content message's opaque provider `fidelity` payload (thinking signatures, ## Observability -Every approval decision (`approval_decision`), abort (`abort`), compaction (`compaction_begin` / `compaction_end`), and `token_usage` lands in the Trace as an event. The Web Trace view and the usage/cost statistics are both derived from this same data — there is no second source of truth; see the [Web App Guide](/web-app). The approval mechanism itself is covered in [Tools & Approval](/tools). +Every approval decision (`approval_decision`), abort (`abort`), compaction (`compaction_begin` / `compaction_end`), and `token_usage` lands in the Trace as an event. The Web Trace view and the usage/cost statistics are both derived from this same data — there is no second source of truth; see the [Web App Guide](/web-app). The approval mechanism itself is covered in [Tools & Approval](/tools). Trace files can also be moved across deployments from the Web Traces page: any file can be exported (downloaded verbatim as JSONL) and imported back under an Agent — an import whose session id already exists under that Agent is rejected, so an imported file always becomes index 001 of a new Session. diff --git a/packages/docs/content/sessions-and-traces.zh.md b/packages/docs/content/sessions-and-traces.zh.md index 60f312c..ecc3baf 100644 --- a/packages/docs/content/sessions-and-traces.zh.md +++ b/packages/docs/content/sessions-and-traces.zh.md @@ -89,4 +89,4 @@ Web 的 `/model` 命令按 @ handoff 的方式换模型:用普通的会话创 ## 可观测性 -每一次审批决策(`approval_decision`)、中断(`abort`)、压缩(`compaction_begin` / `compaction_end`)与 `token_usage` 都作为事件落入 Trace。Web 的 Trace 视图与用量、成本统计均由这同一份数据派生,不存在第二事实来源;见 [Web App 指南](/web-app)。审批机制本身见[工具与审批](/tools)。 +每一次审批决策(`approval_decision`)、中断(`abort`)、压缩(`compaction_begin` / `compaction_end`)与 `token_usage` 都作为事件落入 Trace。Web 的 Trace 视图与用量、成本统计均由这同一份数据派生,不存在第二事实来源;见 [Web App 指南](/web-app)。审批机制本身见[工具与审批](/tools)。Trace 文件还可以在部署之间迁移:在 Web 轨迹观测页可将任意文件导出(按原样下载为 JSONL),也可导入到某个 Agent 下——若该 Agent 已存在同名 Session 则导入被拒绝,因此导入文件总是成为一个新 Session 的 001 号文件。 diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index 20b4923..8cd2588 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -939,6 +939,20 @@ export interface AgentTracesResponse { dates: AgentTraceDateGroup[]; } +export interface TraceImportRequest { + /** Base64 of the Trace file content (JSON Lines; the first record must be `session_meta`). */ + dataBase64: string; +} + +export interface TraceImportResponse { + /** Session id taken from the imported file's `session_meta`. */ + sessionId: string; + /** Allocated file index: always 1 — an import creates a new Session (a duplicate session id is rejected with 409 `trace_session_exists`). */ + index: number; + /** Date directory the file landed in (local yyyy-mm-dd from the first record's timestamp, matching the Trace Writer's convention). */ + date: string; +} + // --------------------------------------------------------------------------- // Usage and cost statistics // --------------------------------------------------------------------------- diff --git a/packages/server/src/http/routes/agent-traces.ts b/packages/server/src/http/routes/agent-traces.ts index 49a3cae..84f6df1 100644 --- a/packages/server/src/http/routes/agent-traces.ts +++ b/packages/server/src/http/routes/agent-traces.ts @@ -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 { const app = new Hono(); @@ -44,5 +59,47 @@ export function agentTracesRoutes(deps: AppDeps): Hono { 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; } diff --git a/packages/server/src/services/trace-service.ts b/packages/server/src/services/trace-service.ts index 45343ae..6fc43c7 100644 --- a/packages/server/src/services/trace-service.ts +++ b/packages/server/src/services/trace-service.ts @@ -13,6 +13,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { agentsDir, + isSessionMeta, + parseTraceLines, parseUserSteeringText, readTraceTolerant, tracesDir, @@ -25,15 +27,24 @@ import type { TraceAnalysisResponse, TraceEventsResponse, TraceFileInfo, + TraceImportResponse, TraceModelSegment, TraceTaskStats, TraceToolSpan, UsageTrendPointInTrace, } from "../api/types.js"; import { HttpError } from "../http/errors.js"; +import { formatLocalDate } from "../internal/dates.js"; const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/; +/** + * session_id an imported Trace file may declare: same alphabet as resource ids plus a length + * cap. The value becomes part of the target **filename**, so this is a path-traversal defense, + * checked right next to the path construction — never trust the caller to have validated it. + */ +const IMPORT_SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; + /** Recursion depth cap for sub-session expansion (run_subagent depth is already constrained by the SDK; this is just a defensive backstop against cycles). */ const MAX_SUBAGENT_DEPTH = 4; @@ -687,6 +698,16 @@ export class TraceService { sessionId: string, index: number, ): Promise { + const file = await this.locateByIndex(projectId, agentId, sessionId, index); + return readTraceTolerant(file.path); + } + + private async locateByIndex( + projectId: string, + agentId: string, + sessionId: string, + index: number, + ): Promise { const files = await this.locateAll(projectId, agentId, sessionId); const file = files.find((f) => f.index === index); if (!file) { @@ -696,6 +717,80 @@ export class TraceService { `This Session has no Trace file with index ${index}.`, ); } - return readTraceTolerant(file.path); + return file; + } + + /** Raw bytes of the Trace file at the given index (export/download: the file is served verbatim). */ + async readFileRaw( + projectId: string, + agentId: string, + sessionId: string, + index: number, + ): Promise { + const file = await this.locateByIndex(projectId, agentId, sessionId, index); + return fs.readFile(file.path); + } + + /** + * Imports an uploaded Trace file (raw JSONL content). Validates the content itself — + * parseable JSONL whose first record is a `session_meta` carrying a filename-safe + * `session_id` (400 invalid_trace otherwise) — then writes it under the Agent's traces + * directory as a **new Session**: a session id the Agent already has is rejected with + * 409 `trace_session_exists` (splicing a further index into an existing Session would + * corrupt its concatenated transcript, silently become its resume point, and could + * collide with a live Writer's rotation), so the imported file is always index 1. The + * date dir comes from the first record's timestamp (falling back to now), formatted as + * a **local** date — the same convention as core's Trace Writer — so an export → + * import round-trip lands in the same date dir on non-UTC servers. + */ + async importTraceFile( + projectId: string, + agentId: string, + content: string, + ): Promise { + const invalid = (message: string) => new HttpError(400, "invalid_trace", message); + let records: OmniMessage[]; + try { + records = parseTraceLines(content); + } catch { + throw invalid("The file is not valid Trace JSONL."); + } + if (records.length === 0) throw invalid("The file contains no Trace records."); + const first = records[0]!; + if (!isSessionMeta(first)) + throw invalid("The first record of a Trace file must be session_meta."); + // The payload type declares session_id: string, but the value came from user-supplied JSON — + // re-check the runtime shape before it becomes part of a filename. + const sessionId: unknown = (first.payload as { session_id?: unknown }).session_id; + if (typeof sessionId !== "string" || !IMPORT_SESSION_ID_RE.test(sessionId)) { + throw invalid("session_meta carries a missing or invalid session_id."); + } + const duplicate = () => + new HttpError( + 409, + "trace_session_exists", + `This Agent already has a Session with id ${sessionId}; a duplicate Trace cannot be imported.`, + ); + if ((await this.locateAll(projectId, agentId, sessionId)).length > 0) throw duplicate(); + const ts = Date.parse(first.timestamp); + const date = formatLocalDate(Number.isNaN(ts) ? new Date() : new Date(ts)); + const index = 1; + const dir = path.join(tracesDir(this.root, projectId, agentId), date); + await fs.mkdir(dir, { recursive: true }); + const file = path.join(dir, `${sessionId}_${String(index).padStart(3, "0")}.jsonl`); + try { + // Normalize to exactly one trailing newline (the JSONL convention the writer + // follows). `wx` closes the check-then-write race: two concurrent imports of the + // same new session id both pass the locateAll check, but only one can create the + // file — the loser's EEXIST maps to the same 409 as the pre-check. + await fs.writeFile(file, content.replace(/\n+$/, "") + "\n", { + encoding: "utf8", + flag: "wx", + }); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EEXIST") throw duplicate(); + throw err; + } + return { sessionId, index, date }; } } diff --git a/packages/server/test/trace-import-export.test.ts b/packages/server/test/trace-import-export.test.ts new file mode 100644 index 0000000..58dfa4b --- /dev/null +++ b/packages/server/test/trace-import-export.test.ts @@ -0,0 +1,231 @@ +/** + * Trace file export/import integration tests: + * export serves the raw JSONL verbatim as an attachment (any member); import validates the + * uploaded content (parseable JSONL, leading session_meta, filename-safe session_id) and + * always stores it as index 1 of a new Session — a session id the Agent already has is + * rejected with 409 trace_session_exists — and the file is then browsable through the + * existing tree/detail endpoints. Import is owner-only, mirroring the Agent snapshot import. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { OmniMessage, SessionMetaPayload } from "@prismshadow/penguin-core"; +import type { + AgentTracesResponse, + ProjectCreateResponse, + TraceEventsResponse, + TraceImportResponse, +} from "../src/api/types.js"; +import { apiClient, createTestApp, provisionUser, writeTraceFile } from "./helpers.js"; +import type { TestApp } from "./helpers.js"; + +const SID = "session-2026-07-06-09-00-00-feed0001"; + +function metaPayload(sessionId: string): SessionMetaPayload { + return { + session_id: sessionId, + model_id: "m", + provider: "custom", + model_context_window: 1000, + system_prompt: "", + tools: [], + agent_state: "/tmp/a", + workspace: "/tmp/w", + }; +} + +/** Envelope with a fixed timestamp (the builders stamp "now"; import derives the date dir from it). */ +const rec = (timestamp: string, type: OmniMessage["type"], payload: unknown): OmniMessage => + ({ timestamp, type, payload }) as OmniMessage; + +/** First record's timestamp: 02:00 UTC, so on runners west of UTC the local date differs from the UTC date. */ +const FIRST_TS = "2026-07-06T02:00:00.000Z"; + +function sampleTrace(sessionId: string): OmniMessage[] { + return [ + rec(FIRST_TS, "session_meta", metaPayload(sessionId)), + rec("2026-07-06T02:00:01.000Z", "model_msg", { + type: "text", + role: "user", + text: "imported input", + }), + rec("2026-07-06T02:00:02.000Z", "event_msg", { type: "request_begin" }), + rec("2026-07-06T02:00:03.000Z", "event_msg", { type: "request_end", status: "completed" }), + ]; +} + +/** + * Local yyyy-mm-dd of a timestamp — the import derives its date dir with local-date + * formatting (core Trace Writer convention), not UTC. Computing the expectation the same + * way keeps these tests timezone-independent, and on a non-UTC runner they fail if the + * import regresses to UTC (FIRST_TS is chosen so the two dates differ west of UTC). + */ +function localDateOf(iso: string): string { + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; +} + +const toContent = (messages: OmniMessage[]): string => + messages.map((m) => JSON.stringify(m)).join("\n") + "\n"; +const b64 = (s: string): string => Buffer.from(s, "utf8").toString("base64"); + +async function errorCode(res: Response): Promise { + const body = (await res.json()) as { error: { code: string } }; + return body.error.code; +} + +describe("trace-import-export", () => { + let t: TestApp; + let owner: ReturnType; + let member: ReturnType; + let outsider: ReturnType; + let projectId: string; + const base = () => `/api/projects/${projectId}/agents/default_agent/traces`; + + beforeEach(async () => { + t = await createTestApp(); + const a = await provisionUser(t.app, "owner"); + const b = await provisionUser(t.app, "member_b"); + const c = await provisionUser(t.app, "outsider"); + owner = apiClient(t.app, a.cookie); + member = apiClient(t.app, b.cookie); + outsider = apiClient(t.app, c.cookie); + const created = (await ( + await owner.post("/api/projects", { projectId: "owner-trace_io", name: "Trace IO" }) + ).json()) as ProjectCreateResponse; + projectId = created.project.projectId; + expect( + (await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" })).status, + ).toBe(201); + }); + afterEach(async () => { + await t.cleanup(); + }); + + it("export: serves the raw file with attachment headers (any member)", async () => { + const messages = sampleTrace(SID); + await writeTraceFile(t.root, projectId, "default_agent", "2026-07-06", SID, 1, messages); + const res = await owner.get(`${base()}/${SID}/1/download`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/x-ndjson"); + expect(res.headers.get("content-disposition")).toBe( + `attachment; filename*=UTF-8''${SID}_001.jsonl`, + ); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(await res.text()).toBe(toContent(messages)); + // A plain member can export too (same rule as the snapshot export). + expect((await member.get(`${base()}/${SID}/1/download`)).status).toBe(200); + }); + + it("export: unknown index → 404 trace_not_found; outsider → 404", async () => { + await writeTraceFile( + t.root, + projectId, + "default_agent", + "2026-07-06", + SID, + 1, + sampleTrace(SID), + ); + const missing = await owner.get(`${base()}/${SID}/9/download`); + expect(missing.status).toBe(404); + expect(await errorCode(missing)).toBe("trace_not_found"); + // No Project access → uniform 404 (requireProjectAccess does not leak existence). + expect((await outsider.get(`${base()}/${SID}/1/download`)).status).toBe(404); + }); + + it("import: stores the file as index 1 and it becomes browsable through the existing endpoints", async () => { + const content = toContent(sampleTrace(SID)).trimEnd(); // uploaded without a trailing newline + const res = await owner.post(`${base()}/import`, { dataBase64: b64(content) }); + expect(res.status).toBe(200); + const body = (await res.json()) as TraceImportResponse; + expect(body).toEqual({ sessionId: SID, index: 1, date: localDateOf(FIRST_TS) }); + // Appears in the drill-down tree under the local date derived from the first record's timestamp. + const tree = (await (await owner.get(base())).json()) as AgentTracesResponse; + expect(tree.dates).toHaveLength(1); + expect(tree.dates[0]!.date).toBe(localDateOf(FIRST_TS)); + expect(tree.dates[0]!.sessions[0]!.sessionId).toBe(SID); + expect(tree.dates[0]!.sessions[0]!.files.map((f) => f.index)).toEqual([1]); + // Events are readable via the existing detail endpoint. + const events = (await (await owner.get(`${base()}/${SID}/1`)).json()) as TraceEventsResponse; + expect(events.total).toBe(4); + expect((events.events[1]!.payload as { text: string }).text).toBe("imported input"); + // Round-trip: the stored content is normalized to exactly one trailing newline. + expect(await (await owner.get(`${base()}/${SID}/1/download`)).text()).toBe(content + "\n"); + }); + + it("import: duplicate session id → 409 trace_session_exists, nothing written", async () => { + const original = sampleTrace(SID); + // An existing file in any date dir counts — even a different date than the upload's. + await writeTraceFile(t.root, projectId, "default_agent", "2026-07-05", SID, 1, original); + const res = await owner.post(`${base()}/import`, { + dataBase64: b64(toContent(sampleTrace(SID))), + }); + expect(res.status).toBe(409); + expect(await errorCode(res)).toBe("trace_session_exists"); + // Nothing was written: the tree still shows only the pre-existing file, intact. + const tree = (await (await owner.get(base())).json()) as AgentTracesResponse; + expect(tree.dates).toHaveLength(1); + expect(tree.dates[0]!.date).toBe("2026-07-05"); + expect(tree.dates[0]!.sessions[0]!.files.map((f) => f.index)).toEqual([1]); + expect(await (await owner.get(`${base()}/${SID}/1/download`)).text()).toBe(toContent(original)); + }); + + it("import: concurrent imports of the same new session id — exactly one wins", async () => { + // Whichever request loses is rejected either by the pre-check (locateAll) or by the + // exclusive `wx` write (EEXIST → the same 409), so the observable outcome is + // deterministic even though the internal interleaving isn't: one 200, one 409, and + // exactly one stored file. + const content = toContent(sampleTrace(SID)); + const [a, b] = await Promise.all([ + owner.post(`${base()}/import`, { dataBase64: b64(content) }), + owner.post(`${base()}/import`, { dataBase64: b64(content) }), + ]); + expect([a.status, b.status].sort((x, y) => x - y)).toEqual([200, 409]); + expect(await errorCode(a.status === 409 ? a : b)).toBe("trace_session_exists"); + const tree = (await (await owner.get(base())).json()) as AgentTracesResponse; + expect(tree.dates).toHaveLength(1); + expect(tree.dates[0]!.sessions[0]!.files.map((f) => f.index)).toEqual([1]); + }); + + it("import: malformed middle line → 400 invalid_trace", async () => { + const lines = toContent(sampleTrace(SID)).split("\n"); + lines[1] = "{not json"; // corrupt a middle line (non-final: parseTraceLines throws loudly) + const res = await owner.post(`${base()}/import`, { dataBase64: b64(lines.join("\n")) }); + expect(res.status).toBe(400); + expect(await errorCode(res)).toBe("invalid_trace"); + }); + + it("import: first record not session_meta → 400 invalid_trace", async () => { + const content = toContent([ + rec("2026-07-06T09:00:00.000Z", "model_msg", { type: "text", role: "user", text: "x" }), + ]); + const res = await owner.post(`${base()}/import`, { dataBase64: b64(content) }); + expect(res.status).toBe(400); + expect(await errorCode(res)).toBe("invalid_trace"); + }); + + it("import: session_meta with a filename-unsafe session_id → 400 invalid_trace", async () => { + const content = toContent([ + rec("2026-07-06T09:00:00.000Z", "session_meta", metaPayload("../../escape")), + ]); + const res = await owner.post(`${base()}/import`, { dataBase64: b64(content) }); + expect(res.status).toBe(400); + expect(await errorCode(res)).toBe("invalid_trace"); + }); + + it("import: payload over 14MB → 400 (same cap style as the snapshot import)", async () => { + const res = await owner.post(`${base()}/import`, { + dataBase64: b64("a".repeat(14 * 1024 * 1024 + 1)), + }); + expect(res.status).toBe(400); + }); + + it("import: owner only — member 403, outsider 404, unknown agent 404", async () => { + const body = { dataBase64: b64(toContent(sampleTrace(SID))) }; + expect((await member.post(`${base()}/import`, body)).status).toBe(403); + expect((await outsider.post(`${base()}/import`, body)).status).toBe(404); + expect( + (await owner.post(`/api/projects/${projectId}/agents/ghost/traces/import`, body)).status, + ).toBe(404); + }); +}); diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index 58c4271..4053a5b 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -57,6 +57,8 @@ import type { TaskCreateResponse, TraceAnalysisResponse, TraceEventsResponse, + TraceImportRequest, + TraceImportResponse, UiPrefs, UsageGroupBy, UsageResponse, @@ -332,6 +334,23 @@ export const getAgentTraceAnalysis = ( `/traces/${encodeURIComponent(sessionId)}/${index}/analysis`, ); +/** Trace file download URL: the server sets Content-Disposition attachment, usable directly in . */ +export const agentTraceDownloadUrl = ( + projectId: string, + agentId: string, + sessionId: string, + index: number, +): string => + `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}` + + `/traces/${encodeURIComponent(sessionId)}/${index}/download`; + +/** Imports a Trace JSONL file (owner only); the response says where the file landed (sessionId / index / date). */ +export const importAgentTrace = (projectId: string, agentId: string, body: TraceImportRequest) => + apiFetch( + `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/traces/import`, + { method: "POST", body }, + ); + // Usage statistics ---------------------------------------------------------------------- export const getUsage = ( diff --git a/packages/web/src/components/ui/icons.tsx b/packages/web/src/components/ui/icons.tsx index 00167f3..cf17805 100644 --- a/packages/web/src/components/ui/icons.tsx +++ b/packages/web/src/components/ui/icons.tsx @@ -70,6 +70,50 @@ export function PlusIcon({ ); } +/** Download glyph (tray with a down arrow), used by export/download affordances. */ +export function DownloadIcon({ size = 13, className = "" }: { size?: number; className?: string }) { + return ( + + + + ); +} + +/** Upload glyph (tray with an up arrow), used by import/upload affordances. */ +export function UploadIcon({ size = 13, className = "" }: { size?: number; className?: string }) { + return ( + + + + ); +} + /** * The X close button shared by the Modal / Drawer / Sheet headers: same glyph, * padding and hover treatment. Extra button props (e.g. Sheet's onPointerDown diff --git a/packages/web/src/features/traces/traces-page.tsx b/packages/web/src/features/traces/traces-page.tsx index 3659b48..ea07f1a 100644 --- a/packages/web/src/features/traces/traces-page.tsx +++ b/packages/web/src/features/traces/traces-page.tsx @@ -6,6 +6,7 @@ * analysis (an execution timeline) + an event timeline. */ import { useEffect, useRef, useState } from "react"; +import type { ChangeEvent } from "react"; import { useSearchParams } from "react-router"; import type { AgentTracesResponse } from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; @@ -16,6 +17,8 @@ import { formatBytes } from "../../lib/format"; import { agentDisplayName, useProject } from "../../state/project"; import { AgentAvatar } from "../../components/ui/agent-avatar"; import { Chevron } from "../../components/ui/chevron"; +import { DownloadIcon, UploadIcon } from "../../components/ui/icons"; +import { toastError } from "../../components/ui/toast"; import { Truncated } from "../../components/ui/truncated"; import { useSessions } from "../../state/sessions"; import { EmptyState } from "../../components/ui/empty-state"; @@ -23,6 +26,14 @@ import { SkeletonList } from "../../components/ui/skeleton"; import { TraceFileView } from "./trace-file-view"; import type { TraceHighlight } from "./timeline-chart"; +/** + * Import file size cap, mirroring the server's route-side limit (agent-traces.ts). + * Checked on the raw picked file before it is read: base64-encoding an oversized + * pick and uploading it just to receive the server's 400 would materialize and + * send many times the cap for nothing. + */ +const MAX_TRACE_BYTES = 14 * 1024 * 1024; + interface TraceFileRef { index: number; date: string; @@ -67,6 +78,7 @@ function AgentNode({ name, defaultOpen, focusSessionId, + canImport, titleOf, selection, onSelect, @@ -78,6 +90,8 @@ function AgentNode({ defaultOpen: boolean; /** ?sessionId= deep link (jumped to directly from the evaluation center's runs): auto-selects that Session once the list is ready (only once). */ focusSessionId?: string; + /** Trace import is owner-only on the server; non-owners don't get the button. */ + canImport: boolean; titleOf: (agentId: string, sessionId: string) => string | undefined; selection: Selection | null; onSelect: (sel: Selection) => void; @@ -85,6 +99,9 @@ function AgentNode({ const [open, setOpen] = useState(defaultOpen); const [groups, setGroups] = useState(null); const [error, setError] = useState(null); + const [importing, setImporting] = useState(false); + /** Session to auto-select once the refreshed list arrives (the import response's sessionId). */ + const importedSession = useRef(null); useEffect(() => { if (!open || groups) return; @@ -106,6 +123,53 @@ function AgentNode({ if (target) onSelect({ agentId, sessionId: target.sessionId, files: target.files }); }, [groups, focusSessionId, agentId, onSelect]); + // Post-import selection: once the refreshed list is in, select the imported + // Session — an import always creates a new Session whose only file is the + // imported one, so the default file pick is the imported file. + useEffect(() => { + const sid = importedSession.current; + if (sid === null || !groups) return; + importedSession.current = null; + const target = groups.find((g) => g.sessionId === sid); + if (target) onSelect({ agentId, sessionId: target.sessionId, files: target.files }); + }, [groups, agentId, onSelect]); + + const runImport = async (dataBase64: string) => { + setImporting(true); + try { + const res = await api.importAgentTrace(projectId, agentId, { dataBase64 }); + // Drop the cached list so the fetch effect above reloads it; the effect + // watching `groups` then jumps to the imported Session. + importedSession.current = res.sessionId; + setGroups(null); + setOpen(true); + } catch (e: unknown) { + // Transient action failure → toast (the app's one notification rule; a + // rejected import isn't a state of the tree, unlike the load error below). + toastError(apiErrorText(e)); + } finally { + setImporting(false); + } + }; + + const onPickFile = (e: ChangeEvent) => { + const file = e.target.files?.[0]; + // Reset before reading so re-picking the same file fires change again. + e.target.value = ""; + if (!file) return; + if (file.size > MAX_TRACE_BYTES) { + toastError(S.traces.fileTooLarge); + return; + } + const reader = new FileReader(); + reader.onload = () => { + const url = reader.result as string; + void runImport(url.slice(url.indexOf(",") + 1)); // strip the data:...;base64, prefix + }; + reader.onerror = () => toastError(S.common.unknownError); + reader.readAsDataURL(file); + }; + // The group header and Session row styling matches the sidebar // (components/layout/sidebar.tsx): the same information appearing in two // places with a different shape would make it look like two different things. @@ -126,9 +190,29 @@ function AgentNode({ + {canImport && ( + + )} {open && (
+ {/* Load failure of the tree itself stays inline (the one-notification-rule keeps load states with their content, not in a disappearing toast). */} {error &&

{error}

} {!groups && !error && (

{S.common.loading}

@@ -232,6 +316,7 @@ export function TracesPage() { {...(focusSessionId !== null && focusAgentId === a.agentId ? { focusSessionId } : {})} + canImport={currentProject?.role === "owner"} titleOf={titleOf} selection={selection} onSelect={(sel) => { @@ -275,9 +360,25 @@ export function TracesPage() { ))}
-

- {selection.sessionId} · {activeFile.date} · {formatBytes(activeFile.sizeBytes)} -

+
`Tool definitions (${n})`, + exportFile: "Export", + importTrace: "Import Trace", + importing: "Importing…", + /** Client-side pre-check before reading the picked file (same cap as the server's import route). */ + fileTooLarge: "The file exceeds the 14MB limit.", }, benchmark: { @@ -899,6 +904,9 @@ When done, open index.html in a browser and self-test once.`, task_in_progress: "This Session already has a task running.", version_conflict: "The snapshot's version is not newer than the current one.", invalid_title: "The title is invalid.", + invalid_trace: "This file is not a valid Trace file.", + trace_session_exists: + "This agent already has a Session with that id; a duplicate Trace cannot be imported.", }, }, }; diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index 796ad94..e2c2821 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -825,6 +825,11 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题( `工具定义(${n})`, + exportFile: "导出", + importTrace: "导入 Trace", + importing: "导入中…", + /** Client-side pre-check before reading the picked file (same cap as the server's import route). */ + fileTooLarge: "文件超过 14MB 上限。", }, benchmark: { @@ -882,6 +887,8 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(