diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index 38f5f87..31ffed5 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -1093,9 +1093,58 @@ export interface AgentTraceDateGroup { sessions: AgentTraceSessionGroup[]; } -/** Agent → date → Session → Trace file drill-down browsing structure (reverse chronological). */ +/** One Trace file in the session-centric listing (`date` carried per file: one Session's shards can span date directories). */ +export interface AgentTraceSessionFile { + index: number; + date: string; + sizeBytes: number; +} + +/** One Session's Trace files merged across date directories (the paginated listing's unit). */ +export interface AgentTraceSessionEntry { + sessionId: string; + /** + * Display title, resolved only for the returned page: the sessions DB title when one + * exists, else derived from the Session's first user prompt (bounded head-read of the + * earliest shard); absent when neither yields one (the client falls back to its + * default title — raw session ids are never rendered). + */ + title?: string; + /** + * Sidebar category of this Session, from the same bounded classification the listing + * filters and counts with: archived exactly from the DB row; origin from the shared + * in-process registry / previously observed session_meta; a DB-untracked Session this + * process has not yet head-read falls into `active` until a page surfaces it (its + * head-read then registers the true origin for subsequent requests). + */ + category: SessionCategory; + /** Workspace path locked at creation (DB row or observed session_meta); "" when unknown — the client's merged temp-group fallback. */ + workspace: string; + /** Sorted by index ascending (a higher index is newer). */ + files: AgentTraceSessionFile[]; +} + +/** + * Agent-level Trace browsing structure. Without `limit` the response is the legacy full + * drill-down (`dates`: Agent → date → Session → Trace file, reverse chronological) and the + * paging fields are absent. With `offset`/`limit` the response is session-group-centric: + * `sessions` carries the requested slice (newest first by sessionId desc — ids embed a + * timestamp, so that is reverse chronological) with titles and classification, + * `totalSessions` the session-group count (within `category` when one is given, so paging + * and the count agree), `counts` / `workspaceCounts` the per-category totals over ALL of + * the Agent's session groups (folder labels / workspace-mode group headers), and `dates` + * stays empty (per-file stats are only taken for the returned page). + */ export interface AgentTracesResponse { dates: AgentTraceDateGroup[]; + /** Present only when the request paginates: the requested slice of Session groups, newest first. */ + sessions?: AgentTraceSessionEntry[]; + /** Present only when the request paginates: session-group count of the paged (category-filtered) set. */ + totalSessions?: number; + /** Present only when the request paginates: per-category totals over all of the Agent's session groups. */ + counts?: SessionCategoryCounts; + /** Present only when the request paginates: `counts` broken down by Workspace path ("" = unknown). */ + workspaceCounts?: Record; } export interface TraceImportRequest { diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 340a0a9..7fc33a1 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -23,6 +23,7 @@ import { ProjectsRepo } from "./db/repos/projects.js"; import { GoalsRepo } from "./db/repos/goals.js"; import { SchedulesRepo } from "./db/repos/schedules.js"; import { SessionsRepo } from "./db/repos/sessions.js"; +import { TraceIndexRepo } from "./db/repos/trace-index.js"; import { UiPrefsRepo } from "./db/repos/ui-prefs.js"; import { UsageRepo } from "./db/repos/usage.js"; import { UsersRepo } from "./db/repos/users.js"; @@ -70,6 +71,7 @@ import { SnapshotService } from "./services/snapshot-service.js"; import { ProjectConfigService } from "./services/project-config-service.js"; import { ProjectService } from "./services/project-service.js"; import { SessionService } from "./services/session-service.js"; +import { TraceIndexService } from "./services/trace-index.js"; import { TraceService } from "./services/trace-service.js"; import { UpdateCheckService } from "./services/update-check-service.js"; import { UsageService } from "./services/usage-service.js"; @@ -99,6 +101,8 @@ export interface AppDeps { agentConfigService: AgentConfigService; sessionService: SessionService; traceService: TraceService; + /** Trace-file index (derived cache + reconciler); routes use it for delete-time coherence. */ + traceIndex: TraceIndexService; usageService: UsageService; /** GitHub latest-release lookup for the web UI's update reminder (cached, fail-soft). */ updateCheck: UpdateCheckService; @@ -154,7 +158,20 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides const projectConfigService = new ProjectConfigService(config.root); const agentConfigService = new AgentConfigService(config.root); const agentService = new AgentService(config.root, agentsRepo, agentConfigService); - const traceService = new TraceService(config.root); + // Session-origin registry: session_meta is the single source of truth (no DB column); + // shared by the manager (subagent registration), the loader (self-heal rebuild), + // SessionService (creation / adoption / lazy list resolution), and the Trace index / + // listing classification. + const sessionSources = new SessionSources(); + // Trace-file index: the derived cache every trace listing/locating path serves from + // (mtime-gated reconciler keeps it in step with the on-disk tree; see trace-index.ts). + const traceIndexRepo = new TraceIndexRepo(db); + const traceIndex = new TraceIndexService(config.root, traceIndexRepo, sessionSources); + const traceService = new TraceService(config.root, { + index: traceIndex, + sessions: sessionsRepo, + sources: sessionSources, + }); const workspaceFiles = new WorkspaceFilesService(); // Per-process secret: preview tokens are short-lived, so losing them on restart is // harmless and there is nothing to persist or rotate. @@ -183,10 +200,6 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides const titles = overrides.titles ?? new TitleGenerator({ sessions: sessionsRepo, channels, recorder, errors, log }); - // Session-origin registry: session_meta is the single source of truth (no DB column); - // shared by the manager (subagent registration), the loader (self-heal rebuild) and - // SessionService (creation / adoption / lazy list resolution). - const sessionSources = new SessionSources(); const manager = new SessionManager({ sessions: sessionsRepo, channels, @@ -213,6 +226,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides goals: goalsRepo, projectConfig: projectConfigService, manager, + traceIndex, }); const authService = new AuthService({ users: usersRepo, @@ -237,6 +251,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides manager, projectConfig: projectConfigService, sources: sessionSources, + traceIndex, }); // Schedule scheduler: active only while the server is running. Only // assembled here; start() is called in index.ts (tests drive it via tickOnce, no real timer). @@ -267,6 +282,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides agentConfigService, sessionService, traceService, + traceIndex, usageService, updateCheck, workspaceFiles, diff --git a/packages/server/src/db/repos/trace-index.ts b/packages/server/src/db/repos/trace-index.ts new file mode 100644 index 0000000..76db3b1 --- /dev/null +++ b/packages/server/src/db/repos/trace-index.ts @@ -0,0 +1,187 @@ +/** + * trace_files / trace_sessions repo: the Trace-tree derived cache (see schema.ts and + * services/trace-index.ts for the cache rules). Pure row access — reconciliation + * policy (mtime gates, head-reads) lives in the service. + */ +import type { DatabaseSync } from "node:sqlite"; +import type { SessionSource } from "../../api/types.js"; + +/** One indexed Trace shard. */ +export interface TraceFileRow { + projectId: string; + agentId: string; + sessionId: string; + fileIndex: number; + date: string; + sizeBytes: number; +} + +/** One Session's registration-time facts (earliest shard's head). */ +export interface TraceSessionRow { + sessionId: string; + projectId: string; + agentId: string; + source: SessionSource | null; + workspace: string; + title: string | null; + provider: string | null; + modelId: string | null; + firstTs: string | null; + metaRead: boolean; +} + +function mapFile(r: Record): TraceFileRow { + return { + projectId: r.project_id as string, + agentId: r.agent_id as string, + sessionId: r.session_id as string, + fileIndex: r.file_index as number, + date: r.date as string, + sizeBytes: r.size_bytes as number, + }; +} + +function mapSession(r: Record): TraceSessionRow { + const source = r.source as string | null; + return { + sessionId: r.session_id as string, + projectId: r.project_id as string, + agentId: r.agent_id as string, + // Narrowed on read as well as write: junk in a hand-edited DB must not leak out as a source. + source: source === "subagent" || source === "schedule" ? source : null, + workspace: (r.workspace as string | null) ?? "", + title: (r.title as string | null) ?? null, + provider: (r.provider as string | null) ?? null, + modelId: (r.model_id as string | null) ?? null, + firstTs: (r.first_ts as string | null) ?? null, + metaRead: (r.meta_read as number) === 1, + }; +} + +export class TraceIndexRepo { + constructor(private readonly db: DatabaseSync) {} + + upsertFile(row: TraceFileRow): void { + this.db + .prepare( + `INSERT INTO trace_files (project_id, agent_id, session_id, file_index, date, size_bytes) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, agent_id, session_id, file_index) + DO UPDATE SET date = excluded.date, size_bytes = excluded.size_bytes`, + ) + .run(row.projectId, row.agentId, row.sessionId, row.fileIndex, row.date, row.sizeBytes); + } + + /** Refreshes one shard's last-observed size (listings write back their page stats). */ + updateFileSize( + projectId: string, + agentId: string, + sessionId: string, + fileIndex: number, + sizeBytes: number, + ): void { + this.db + .prepare( + `UPDATE trace_files SET size_bytes = ? + WHERE project_id = ? AND agent_id = ? AND session_id = ? AND file_index = ?`, + ) + .run(sizeBytes, projectId, agentId, sessionId, fileIndex); + } + + deleteFile(projectId: string, agentId: string, sessionId: string, fileIndex: number): void { + this.db + .prepare( + `DELETE FROM trace_files + WHERE project_id = ? AND agent_id = ? AND session_id = ? AND file_index = ?`, + ) + .run(projectId, agentId, sessionId, fileIndex); + } + + /** All indexed shards of one Agent (date desc, session desc, index asc — the listing's canonical order). */ + listFilesByAgent(projectId: string, agentId: string): TraceFileRow[] { + const rows = this.db + .prepare( + `SELECT * FROM trace_files WHERE project_id = ? AND agent_id = ? + ORDER BY session_id DESC, file_index ASC`, + ) + .all(projectId, agentId); + return rows.map(mapFile); + } + + /** One Session's shards, index ascending. */ + listFilesBySession(projectId: string, agentId: string, sessionId: string): TraceFileRow[] { + const rows = this.db + .prepare( + `SELECT * FROM trace_files + WHERE project_id = ? AND agent_id = ? AND session_id = ? + ORDER BY file_index ASC`, + ) + .all(projectId, agentId, sessionId); + return rows.map(mapFile); + } + + /** Owning Agent of a Session's Trace within a Project (the subagent-pointer resolver); null when unindexed. */ + findAgentBySession(projectId: string, sessionId: string): string | null { + const r = this.db + .prepare(`SELECT agent_id FROM trace_files WHERE project_id = ? AND session_id = ? LIMIT 1`) + .get(projectId, sessionId); + return r ? (r.agent_id as string) : null; + } + + upsertSession(row: TraceSessionRow): void { + this.db + .prepare( + `INSERT INTO trace_sessions (session_id, project_id, agent_id, source, workspace, title, provider, model_id, first_ts, meta_read) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + project_id = excluded.project_id, agent_id = excluded.agent_id, + source = excluded.source, workspace = excluded.workspace, title = excluded.title, + provider = excluded.provider, model_id = excluded.model_id, + first_ts = excluded.first_ts, meta_read = excluded.meta_read`, + ) + .run( + row.sessionId, + row.projectId, + row.agentId, + row.source, + row.workspace, + row.title, + row.provider, + row.modelId, + row.firstTs, + row.metaRead ? 1 : 0, + ); + } + + getSession(sessionId: string): TraceSessionRow | null { + const r = this.db.prepare("SELECT * FROM trace_sessions WHERE session_id = ?").get(sessionId); + return r ? mapSession(r) : null; + } + + listSessionsByAgent(projectId: string, agentId: string): TraceSessionRow[] { + const rows = this.db + .prepare("SELECT * FROM trace_sessions WHERE project_id = ? AND agent_id = ?") + .all(projectId, agentId); + return rows.map(mapSession); + } + + /** Removes one Session's rows from both tables (Session delete keeps the cache coherent). */ + deleteBySession(sessionId: string): void { + this.db.prepare("DELETE FROM trace_files WHERE session_id = ?").run(sessionId); + this.db.prepare("DELETE FROM trace_sessions WHERE session_id = ?").run(sessionId); + } + + deleteByAgent(projectId: string, agentId: string): void { + this.db + .prepare("DELETE FROM trace_files WHERE project_id = ? AND agent_id = ?") + .run(projectId, agentId); + this.db + .prepare("DELETE FROM trace_sessions WHERE project_id = ? AND agent_id = ?") + .run(projectId, agentId); + } + + deleteByProject(projectId: string): void { + this.db.prepare("DELETE FROM trace_files WHERE project_id = ?").run(projectId); + this.db.prepare("DELETE FROM trace_sessions WHERE project_id = ?").run(projectId); + } +} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index aa519bc..7b504f3 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -121,4 +121,28 @@ CREATE TABLE IF NOT EXISTS ui_prefs ( user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON ); +CREATE TABLE IF NOT EXISTS trace_files ( -- DERIVED CACHE of the on-disk Trace tree (services/trace-index.ts): the directories stay the single source of truth, every row is rebuildable from disk, and a row is never authority for absence — consumers reconcile + retry on a miss, so a stale index costs one extra scan, never a false 404 + project_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + session_id TEXT NOT NULL, + file_index INTEGER NOT NULL, -- shard index NNN of _NNN.jsonl (name/path are reconstructed from the row, never stored: the data root may move) + date TEXT NOT NULL, -- date directory name (local yyyy-mm-dd) + size_bytes INTEGER NOT NULL, -- last observed size (listings stat-refresh the returned page and write back; an actively-appended shard may lag in between) + PRIMARY KEY (project_id, agent_id, session_id, file_index) +); +CREATE INDEX IF NOT EXISTS idx_trace_files_agent_date ON trace_files(project_id, agent_id, date); +CREATE INDEX IF NOT EXISTS idx_trace_files_session ON trace_files(session_id); +CREATE TABLE IF NOT EXISTS trace_sessions ( -- per-session facts read ONCE at registration from the earliest shard's head (same derived-cache rules as trace_files; listing needs zero head-reads) + session_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + source TEXT, -- session_meta origin: 'subagent' | 'schedule' | NULL = user-created (or head not yet readable) + workspace TEXT NOT NULL DEFAULT '', + title TEXT, -- first-prompt fallback title (sessions.title always wins when present) + provider TEXT, -- model reference from session_meta (CLI adoption reads it from here; NULL = meta unreadable / legacy without provider) + model_id TEXT, + first_ts TEXT, -- first record's timestamp (adoption's createdAt fallback when the id embeds no time) + meta_read INTEGER NOT NULL DEFAULT 0 -- 1 once the head parsed; 0 = facts unknown, retried by the next reconcile that touches the session +); +CREATE INDEX IF NOT EXISTS idx_trace_sessions_agent ON trace_sessions(project_id, agent_id); `; diff --git a/packages/server/src/http/routes/agent-traces.ts b/packages/server/src/http/routes/agent-traces.ts index 84f6df1..758c480 100644 --- a/packages/server/src/http/routes/agent-traces.ts +++ b/packages/server/src/http/routes/agent-traces.ts @@ -1,6 +1,18 @@ /** * 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 — Agent-level listing, served from the + * trace-file index (mtime-gated reconcile, then pure DB — see services/trace-index.ts). + * Without `limit`: the legacy full drill-down (Agent -> date -> Session -> index, + * reverse order), never filtered. With optional `offset`/`limit` (+ optional + * `category`, `cli`): pages Session groups newest-first (within the category when + * given), stat-ing only the returned page for fresh sizes, and resolves per group a + * display title (sessions DB title, else the registration-time first-prompt + * fallback) plus its sidebar category / Workspace and per-category totals. + * CLI-origin Sessions (no web sessions-table row, not subagent/schedule) are + * excluded unless `cli=1` — the same "show CLI sessions" preference the sessions + * list honors, applied server-side to rows, counts and workspace groups alike. + * The listing consults the sessions table read-only (titles, archived, workspace, + * client); discovery itself still comes from the Trace directory tree via the index. * - 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 @@ -13,9 +25,10 @@ */ import { Hono } from "hono"; import type { AppEnv } from "../../auth/middleware.js"; -import type { TraceImportResponse } from "../../api/types.js"; +import type { SessionCategory, TraceImportResponse } from "../../api/types.js"; import { badRequest, + optionalPagingQuery, paginationQuery, positiveIntParam, readJson, @@ -27,6 +40,14 @@ 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; +/** Accepted `category` query values of the paginated listing (SessionCategory, spelled out for validation — same as the sessions list route). */ +const SESSION_CATEGORIES: readonly SessionCategory[] = [ + "active", + "subagent", + "schedule", + "archived", +]; + export function agentTracesRoutes(deps: AppDeps): Hono { const app = new Hono(); @@ -35,7 +56,30 @@ export function agentTracesRoutes(deps: AppDeps): Hono { const projectId = requireValidId(c, "projectId"); const agentId = requireValidId(c, "agentId"); deps.projectService.requireProjectAccess(c.var.user.userId, projectId); - return c.json(await deps.traceService.agentTraces(projectId, agentId)); + // Both params absent -> null -> the legacy full response, byte-for-byte as before. + const paging = optionalPagingQuery(c); + // Optional category filter (paging then applies within the category): only meaningful + // on the paginated session-centric shape — the legacy full drill-down has no category + // notion, so a filtered-but-unpaged request is a client error, not a silent no-op. + const rawCategory = c.req.query("category"); + if ( + rawCategory !== undefined && + !(SESSION_CATEGORIES as readonly string[]).includes(rawCategory) + ) { + throw badRequest(`category must be one of ${SESSION_CATEGORIES.join(" / ")}.`); + } + if (rawCategory !== undefined && paging === null) throw badRequest("category requires limit."); + // `cli=1` widens the paginated listing to CLI-origin Sessions (mirroring the sessions + // list's parameter): the default follows the "show CLI sessions" preference's OFF + // state. The legacy unpaged shape is never filtered (back-compat). + const rawCli = c.req.query("cli"); + if (rawCli !== undefined && rawCli !== "1") throw badRequest("cli only accepts 1."); + return c.json( + await deps.traceService.agentTraces(projectId, agentId, paging, { + ...(rawCategory !== undefined ? { category: rawCategory as SessionCategory } : {}), + ...(rawCli !== undefined ? { includeCli: true } : {}), + }), + ); }); app.get("/:sessionId/:index", async (c) => { diff --git a/packages/server/src/http/routes/agents.ts b/packages/server/src/http/routes/agents.ts index 7c1f38d..a61f085 100644 --- a/packages/server/src/http/routes/agents.ts +++ b/packages/server/src/http/routes/agents.ts @@ -76,6 +76,8 @@ export function agentsRoutes(deps: AppDeps): Hono { await settleWithin(runnings, 5000); await deps.agentService.deleteAgent(projectId, agentId); deps.sessionsRepo.deleteByAgent(projectId, agentId); + // Trace-index rows describe files the rm above just removed: drop them with the Agent. + deps.traceIndex.removeAgent(projectId, agentId); // Per-Agent runtime state keyed on the now-removed sessions/agent: drop it so nothing is // orphaned (session ids are never reused, so a leftover row is dead weight). Usage records // are deliberately kept — historical stats survive Agent deletion (see deleteAgent). diff --git a/packages/server/src/internal/trace-head.ts b/packages/server/src/internal/trace-head.ts new file mode 100644 index 0000000..325f795 --- /dev/null +++ b/packages/server/src/internal/trace-head.ts @@ -0,0 +1,41 @@ +/** + * Bounded Trace-head reads (shared by the Session index's session_meta backfill and the + * Trace listing's first-prompt title fallback): parse only the head window of a shard + * instead of pulling a whole multi-MB file into memory. + */ +import { open } from "node:fs/promises"; +import { parseTraceLines, readTraceTolerant } from "@prismshadow/penguin-core"; +import type { OmniMessage } from "@prismshadow/penguin-core"; + +/** Head window for Trace head reads: generous for a long system prompt, far below a whole multi-MB shard. */ +export const TRACE_HEAD_BYTES = 256 * 1024; + +/** + * Parse a Trace file's head window only. session_meta is the first line core writes to + * every shard (and the first user prompt follows right behind it in the earliest shard), + * so a bounded read finds them without pulling the whole file into memory. + * The window is cut at its last newline (the tail fragment is incomplete); a first line + * larger than the whole window falls back to the full tolerant read. + */ +export async function readTraceHead(filePath: string): Promise { + const fh = await open(filePath, "r"); + let text: string; + let truncated: boolean; + try { + // allocUnsafe: only subarray(0, bytesRead) is ever read, so the uninitialized tail never leaks. + const { buffer, bytesRead } = await fh.read( + Buffer.allocUnsafe(TRACE_HEAD_BYTES), + 0, + TRACE_HEAD_BYTES, + 0, + ); + text = buffer.subarray(0, bytesRead).toString("utf8"); + truncated = bytesRead === TRACE_HEAD_BYTES; + } finally { + await fh.close(); + } + if (!truncated) return parseTraceLines(text); + const nl = text.lastIndexOf("\n"); + if (nl === -1) return readTraceTolerant(filePath); + return parseTraceLines(text.slice(0, nl + 1)); +} diff --git a/packages/server/src/runtime/title-generator.ts b/packages/server/src/runtime/title-generator.ts index 35452f2..e2f3529 100644 --- a/packages/server/src/runtime/title-generator.ts +++ b/packages/server/src/runtime/title-generator.ts @@ -135,8 +135,14 @@ export class TitleGenerator implements TitleNotifier { } } -/** Fallback title: take the material's first non-empty line, sanitize and truncate; if sanitizing empties it out (pure punctuation, etc.) fall back to the truncated original text; returns null if all-whitespace. */ -function fallbackTitle(text: string): string | null { +/** + * Fallback title: take the material's first non-empty line, sanitize and truncate; if + * sanitizing empties it out (pure punctuation, etc.) fall back to the truncated original + * text; returns null if all-whitespace. Exported for the Trace listing's title fallback + * (trace-service derives a title from the first user prompt of Sessions the DB has no + * title for) so both fallbacks stay one algorithm. + */ +export function fallbackTitle(text: string): string | null { // Strip machine markers first: a skill invocation prepends a `[use_skills]` block, so the // raw first non-empty line would otherwise be that marker rather than the user's request. const firstLine = stripConversationMarkers(text) diff --git a/packages/server/src/services/project-service.ts b/packages/server/src/services/project-service.ts index bc6e837..8609418 100644 --- a/packages/server/src/services/project-service.ts +++ b/packages/server/src/services/project-service.ts @@ -31,6 +31,7 @@ import { SEMANTIC_ID_RULE, } from "./ids.js"; import type { ProjectConfigService } from "./project-config-service.js"; +import type { TraceIndexService } from "./trace-index.js"; /** Fallback timeout for waiting on runs to settle before deleting a Project. */ const ABORT_SETTLE_TIMEOUT_MS = 5000; @@ -57,6 +58,8 @@ export interface ProjectServiceDeps { goals: GoalsRepo; projectConfig: ProjectConfigService; manager: SessionManager; + /** Trace-index cleanup on Project destruction (rows describe files removed with the Project dir). */ + traceIndex: TraceIndexService; } export class ProjectService { @@ -312,6 +315,7 @@ export class ProjectService { this.deps.errors.deleteByProject(projectId); this.deps.schedules.deleteByProject(projectId); this.deps.goals.deleteByProject(projectId); + this.deps.traceIndex.removeProject(projectId); await fs.rm(projectDir(this.deps.root, projectId), { recursive: true, force: true }); } diff --git a/packages/server/src/services/session-service.ts b/packages/server/src/services/session-service.ts index c173adf..a3b029b 100644 --- a/packages/server/src/services/session-service.ts +++ b/packages/server/src/services/session-service.ts @@ -14,17 +14,7 @@ * Project's default reference, 400 if there is none); the new Session is * added to session-manager's active table (state idle). */ -import path from "node:path"; -import { open, readdir } from "node:fs/promises"; -import { - createAgent, - findLatestTraceFile, - isSessionMeta, - parseTraceLines, - readTraceTolerant, - tracesDir, -} from "@prismshadow/penguin-core"; -import type { SessionMetaMessage } from "@prismshadow/penguin-core"; +import { createAgent, isSessionMeta } from "@prismshadow/penguin-core"; import type { ApprovalMode, SessionCategory, @@ -38,44 +28,11 @@ import type { SessionRow, SessionsRepo } from "../db/repos/sessions.js"; import type { SessionManager } from "../runtime/session-manager.js"; import { asSessionSource } from "../runtime/session-sources.js"; import type { SessionSources } from "../runtime/session-sources.js"; +import { TraceIndexService, traceFilePath } from "./trace-index.js"; import type { ProjectConfigService } from "./project-config-service.js"; -const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/; const SESSION_ID_TS_RE = /^session-(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-[0-9a-f]{8}$/; -/** Head window for session_meta reads: generous for a long system prompt, far below a whole multi-MB shard. */ -const TRACE_HEAD_BYTES = 256 * 1024; - -/** - * Parse a Trace file's head window only. session_meta is the first line core writes to - * every shard, so a bounded read finds it without pulling the whole file into memory — - * category filtering / counts may need every Session's source in a single request. - * The window is cut at its last newline (the tail fragment is incomplete); a first line - * larger than the whole window falls back to the full tolerant read. - */ -async function readTraceHead(filePath: string) { - const fh = await open(filePath, "r"); - let text: string; - let truncated: boolean; - try { - // allocUnsafe: only subarray(0, bytesRead) is ever read, so the uninitialized tail never leaks. - const { buffer, bytesRead } = await fh.read( - Buffer.allocUnsafe(TRACE_HEAD_BYTES), - 0, - TRACE_HEAD_BYTES, - 0, - ); - text = buffer.subarray(0, bytesRead).toString("utf8"); - truncated = bytesRead === TRACE_HEAD_BYTES; - } finally { - await fh.close(); - } - if (!truncated) return parseTraceLines(text); - const nl = text.lastIndexOf("\n"); - if (nl === -1) return readTraceTolerant(filePath); - return parseTraceLines(text.slice(0, nl + 1)); -} - /** Derives creation time from the local timestamp embedded in session_id; returns null if it doesn't match. */ export function sessionIdCreatedAt(sessionId: string): string | null { const m = SESSION_ID_TS_RE.exec(sessionId); @@ -92,6 +49,8 @@ export interface SessionServiceDeps { projectConfig: ProjectConfigService; /** In-process origin registry derived from session_meta (the DB stores no source column). */ sources: SessionSources; + /** Trace-file index: discovery / adoption / stats serve from it (mtime-gated reconciler; no per-request walks). */ + traceIndex: TraceIndexService; } export class SessionService { @@ -103,12 +62,8 @@ export class SessionService { * this process) falls back to reading the Trace head once (see sourceOf). `traces` is the * list flow's one-walk discovery result; without it a miss locates the shard itself. */ - async toInfo( - row: SessionRow, - hasTrace: boolean, - traces?: ReadonlyMap, - ): Promise { - const source = await this.sourceOf(row, hasTrace, traces); + async toInfo(row: SessionRow, hasTrace: boolean): Promise { + const source = await this.sourceOf(row, hasTrace); return { sessionId: row.sessionId, projectId: row.projectId, @@ -130,33 +85,24 @@ export class SessionService { /** * A Session's origin, with session_meta as the single source of truth: the in-process - * registry answers first (populated at creation / subagent registration / adoption); - * on a miss (a Session created before this process started) the earliest Trace shard's - * session_meta is read once and cached. A Session with no Trace yet stays unknown and - * is NOT cached negatively — its meta may appear with the first run. + * registry answers first (populated at creation / subagent registration / adoption / + * index registration); on a miss (a Session created before this process started) the + * trace index's registration-time facts answer — the reconciler head-read the earliest + * shard once when the file first appeared, so no file is touched here. A Session with + * no Trace yet stays unknown and is NOT cached negatively — its meta may appear with + * the first run. */ - private async sourceOf( - row: SessionRow, - hasTrace: boolean, - traces?: ReadonlyMap, - ): Promise { + private async sourceOf(row: SessionRow, hasTrace: boolean): Promise { const known = this.deps.sources.get(row.sessionId); if (known !== undefined) return known ?? undefined; if (!hasTrace) return undefined; - // Single-session paths carry no discovery map: locate this Session's earliest shard on demand. - const location = - traces?.get(row.sessionId) ?? - (await this.discoverTraces(row.projectId, row.agentId)).get(row.sessionId); - if (!location) return undefined; - const meta = await this.readTraceMeta(location); - if (!meta) return undefined; // Unreadable/corrupt Trace: stay unknown, retry on the next list. - // On-disk values are untrusted: only the exact known origins pass, junk = user-created. - const source = asSessionSource(meta.payload.source) ?? null; - this.deps.sources.set(row.sessionId, source); - return source ?? undefined; + const facts = this.deps.traceIndex.repo.getSession(row.sessionId); + if (!facts?.metaRead) return undefined; // Unreadable/unregistered: stay unknown, retry on the next list. + this.deps.sources.set(row.sessionId, facts.source); + return facts.source ?? undefined; } - /** Whether this Session already has a Trace record (a Task has been run). */ + /** Whether this Session already has a Trace record (a Task has been run): answered by the index (reconciled first). */ async hasTrace(row: SessionRow): Promise { return (await this.discoverTraces(row.projectId, row.agentId)).has(row.sessionId); } @@ -167,13 +113,9 @@ export class SessionService { * sidebar's partition applies to loaded rows, so server filtering and client * rendering can never disagree. */ - private async categoryOf( - row: SessionRow, - hasTrace: boolean, - traces?: ReadonlyMap, - ): Promise { + private async categoryOf(row: SessionRow, hasTrace: boolean): Promise { if ((row.archivedAt ?? null) !== null) return "archived"; - return (await this.sourceOf(row, hasTrace, traces)) ?? "active"; + return (await this.sourceOf(row, hasTrace)) ?? "active"; } /** @@ -220,20 +162,22 @@ export class SessionService { .map((r) => [r.sessionId, r]), ); - let traces: ReadonlyMap | undefined; + let traces: ReadonlySet | undefined; if (includeCli) { traces = await this.discoverTraces(projectId, agentId); - // Unmanaged Trace Sessions (the CLI's): backfill an index row by reading the first - // line's session_meta, marked client "cli" so the default list can exclude them. - for (const [sessionId, location] of traces) { + // Unmanaged Trace Sessions (the CLI's): backfill an index row from the trace + // index's registration-time facts, marked client "cli" so the default list can + // exclude them. + for (const sessionId of traces) { if (rows.has(sessionId)) continue; - const discovered = await this.adoptTraceSession(projectId, agentId, sessionId, location); + const discovered = this.adoptTraceSession(projectId, agentId, sessionId); if (discovered) rows.set(sessionId, discovered); } } else if ([...rows.values()].some((r) => this.deps.sources.get(r.sessionId) === undefined)) { - // Hydration walk: some rows predate this process and are unclassified — locate their - // Traces once so sourceOf's head reads (cached afterwards) and the has_trace cache - // don't have to walk per row. Steady state (everything classified) skips this. + // Hydration pass: some rows predate this process and are unclassified — one + // reconciled index read supplies discovery so sourceOf's facts lookups and the + // has_trace cache need no per-row work. Steady state (everything classified) + // skips this. traces = await this.discoverTraces(projectId, agentId); for (const row of rows.values()) { if (!row.hasTrace && traces.has(row.sessionId)) { @@ -249,7 +193,7 @@ export class SessionService { const rowHasTrace = (row: SessionRow): boolean => traces ? traces.has(row.sessionId) : row.hasTrace === true; const toPage = (page: SessionRow[]) => - Promise.all(page.map((row) => this.toInfo(row, rowHasTrace(row), traces))); + Promise.all(page.map((row) => this.toInfo(row, rowHasTrace(row)))); // No classification asked for: slice straight away (the pre-category behavior). if (category === undefined && !withCounts) { @@ -266,7 +210,7 @@ export class SessionService { const matched: SessionRow[] = []; for (const row of sorted) { if (!withCounts && matched.length >= want) break; - const cat = await this.categoryOf(row, rowHasTrace(row), traces); + const cat = await this.categoryOf(row, rowHasTrace(row)); counts[cat] += 1; if (withCounts) { const ws = (workspaceCounts[row.workspace] ??= { @@ -304,13 +248,12 @@ export class SessionService { byDate.set(date, set); }; - // Trace directory: the date directory name is the local date (yyyy-mm-dd) that core uses when writing to disk. - const dir = tracesDir(this.deps.root, projectId, agentId); - for (const dateDir of await listDirsSafe(dir)) { - for (const file of await listFilesSafe(path.join(dir, dateDir))) { - const match = TRACE_FILE_RE.exec(file); - if (match) mark(dateDir, match[1]!); - } + // Trace activity from the index (one mtime-gated reconcile, then a pure DB read — + // this used to walk the Agent's ENTIRE trace history on every agents-list request): + // the date is the shard's date directory (local yyyy-mm-dd, core's writing convention). + await this.deps.traceIndex.reconcileAgent(projectId, agentId); + for (const f of this.deps.traceIndex.repo.listFilesByAgent(projectId, agentId)) { + mark(f.date, f.sessionId); } // DB index: the creation day also counts as active (a Session that hasn't run a Task yet produces no Trace). for (const row of this.deps.sessions.listByAgent(projectId, agentId)) { @@ -432,11 +375,24 @@ export class SessionService { * source history itself when it needs it. */ async latestTracePath(row: SessionRow): Promise { - const located = await findLatestTraceFile( - tracesDir(this.deps.root, row.projectId, row.agentId), + await this.deps.traceIndex.reconcileAgent(row.projectId, row.agentId); + let files = this.deps.traceIndex.repo.listFilesBySession( + row.projectId, + row.agentId, row.sessionId, ); - return located?.path; + if (files.length === 0) { + // Index miss with disk possibly ahead: one forced diff, then retry (the consumers' + // rule — a stale index costs one extra scan, never a missing resume shard). + await this.deps.traceIndex.reconcileAgent(row.projectId, row.agentId, { force: true }); + files = this.deps.traceIndex.repo.listFilesBySession( + row.projectId, + row.agentId, + row.sessionId, + ); + } + const latest = files.at(-1); + return latest === undefined ? undefined : traceFilePath(this.deps.root, latest); } /** @@ -445,65 +401,41 @@ export class SessionService { * records) and the meta-read location come out of a single pass, so classifying every * row (`counts=1`) costs one directory walk total instead of one per Session. */ - private async discoverTraces( - projectId: string, - agentId: string, - ): Promise> { - const dir = tracesDir(this.deps.root, projectId, agentId); - const out = new Map(); - for (const dateDir of await listDirsSafe(dir)) { - for (const file of await listFilesSafe(path.join(dir, dateDir))) { - const match = TRACE_FILE_RE.exec(file); - if (!match) continue; - const sessionId = match[1]!; - const index = Number(match[2]); - const cur = out.get(sessionId); - if (!cur || index < cur.index) { - out.set(sessionId, { path: path.join(dir, dateDir, file), index }); - } - } + private async discoverTraces(projectId: string, agentId: string): Promise> { + await this.deps.traceIndex.reconcileAgent(projectId, agentId); + const out = new Set(); + for (const f of this.deps.traceIndex.repo.listFilesByAgent(projectId, agentId)) { + out.add(f.sessionId); } return out; } /** - * session_meta from a located Trace shard head; null when unreadable or it has no - * meta. Shared by adoption backfill and lazy `source` resolution. + * Adopts a Session that exists only in the Trace directory, from the index's + * registration-time facts (the reconciler head-read the earliest shard's session_meta + * once when the file first appeared — adoption itself reads no file). */ - private async readTraceMeta(location: TraceLocation): Promise { - let messages; - try { - messages = await readTraceHead(location.path); - } catch { - return null; // Corrupt file: skip (does not block the list) - } - return messages.find(isSessionMeta) ?? null; - } - - /** Adopts a Session that exists only in the Trace directory: reads session_meta from the first line of the earliest index file. */ - private async adoptTraceSession( + private adoptTraceSession( projectId: string, agentId: string, sessionId: string, - location: TraceLocation, - ): Promise { - const meta = await this.readTraceMeta(location); - if (!meta) return null; + ): SessionRow | null { + const facts = this.deps.traceIndex.repo.getSession(sessionId); + if (!facts?.metaRead) return null; // Corrupt/unreadable head: skip (does not block the list; retried by a later reconcile) // An older Trace version's session_meta lacks provider (the model reference // wasn't split into separate fields yet): no backward compat, skip adoption // (core will give a clear error on resume; the product hasn't launched yet, so // old data can simply be deleted and recreated). - if (typeof meta.payload.provider !== "string") return null; - // The adoption read already has the meta in hand: record the origin (single source of - // truth); on-disk values are narrowed — junk counts as user-created. - this.deps.sources.set(sessionId, asSessionSource(meta.payload.source) ?? null); + if (facts.provider === null || facts.modelId === null) return null; + // Registration already narrowed the origin; record it in the registry (single source of truth). + this.deps.sources.set(sessionId, facts.source); const row: SessionRow = { sessionId, projectId, agentId, - provider: meta.payload.provider, - modelId: meta.payload.model_id, - workspace: meta.payload.workspace, + provider: facts.provider, + modelId: facts.modelId, + workspace: facts.workspace, // The approval mode for an unmanaged Session (started via the CLI) isn't in the Trace, so it's backfilled with the default value. approvalMode: "allow-all", title: null, @@ -511,7 +443,7 @@ export class SessionService { // (web-only) excludes these rows; the "show CLI sessions" preference includes them. client: "cli", hasTrace: true, - createdAt: sessionIdCreatedAt(sessionId) ?? meta.timestamp, + createdAt: sessionIdCreatedAt(sessionId) ?? facts.firstTs ?? new Date().toISOString(), }; // Idempotent backfill: concurrent list calls may discover the same Session for the first time simultaneously (consistent with AgentsRepo's convention). this.deps.sessions.insertOrIgnore(row); @@ -519,32 +451,8 @@ export class SessionService { } } -/** A located Trace shard of one Session: absolute path plus its shard index. */ -interface TraceLocation { - path: string; - index: number; -} - /** Local date as yyyy-mm-dd (matches the Trace date directory convention: core's internal formatLocalDate, not publicly exported). */ function localDate(d: Date): string { const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } - -async function listDirsSafe(dir: string): Promise { - try { - const entries = await readdir(dir, { withFileTypes: true }); - return entries.filter((e) => e.isDirectory()).map((e) => e.name); - } catch { - return []; - } -} - -async function listFilesSafe(dir: string): Promise { - try { - const entries = await readdir(dir, { withFileTypes: true }); - return entries.filter((e) => e.isFile()).map((e) => e.name); - } catch { - return []; - } -} diff --git a/packages/server/src/services/trace-index.ts b/packages/server/src/services/trace-index.ts new file mode 100644 index 0000000..8e05daf --- /dev/null +++ b/packages/server/src/services/trace-index.ts @@ -0,0 +1,329 @@ +/** + * Trace-file index: keeps the trace_files / trace_sessions derived cache in step with + * the on-disk Trace tree so hot request paths never walk directories or head-read + * files. The DISK remains the single source of truth — every row is rebuildable, and + * the cache is never authority for absence (consumers force-reconcile and retry on a + * miss before erroring). + * + * Keeping in step happens on three paths: + * - Write-time registration: server paths that create/delete shards with known + * identities register synchronously (trace import via registerImportedFile; + * Session delete via removeSession). Server task runs write through core's Trace + * Writer without telling the server which shard rotated — those land via the + * reconciler like external writes (the run bumps the date-dir mtime). + * - mtime-gated reconciliation (reconcileAgent): the hot path stats the Agent's + * traces root plus its newest known date dir (TWO stat calls, no readdir; a new + * date dir bumps the root's mtime, a new file in the current date dir bumps that + * dir's mtime). Only on a change does it readdir — and only the date dirs whose + * mtime moved — registering new shards and classifying each new Session ONCE + * (bounded head-read of the earliest shard for origin/workspace/title). Restart + * forgets the in-memory gate, so the first request per Agent after an upgrade or + * restart runs one full diff (this is also how the index first populates: no + * migration step needed). + * - Forced reconciliation (`force`): ignores the gate and diffs every date dir — + * the miss-retry path for gate blind spots (e.g. an external write into an OLD + * date dir moves neither gated mtime). A stale index therefore degrades to one + * extra scan, never to a false 404. + * + * Single-flight per Agent: concurrent requests share one in-flight reconcile instead + * of stampeding the directory. + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { agentsDir, isSessionMeta, tracesDir } from "@prismshadow/penguin-core"; +import type { OmniMessage } from "@prismshadow/penguin-core"; +import type { TraceFileRow, TraceSessionRow } from "../db/repos/trace-index.js"; +import { TraceIndexRepo } from "../db/repos/trace-index.js"; +import { readTraceHead } from "../internal/trace-head.js"; +import { asSessionSource } from "../runtime/session-sources.js"; +import type { SessionSources } from "../runtime/session-sources.js"; +import { fallbackTitle } from "../runtime/title-generator.js"; + +const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/; + +/** + * mtimes younger than this are treated as UNSTABLE and never cached as clean: + * filesystem timestamps are coarse (a write and a later change can land on the same + * tick), so a fresh directory keeps being re-diffed until it has been quiet for this + * long — an active Agent costs one small readdir per request while writing, and the + * gate can never wedge on a same-tick change. + */ +const FRESH_MS = 2000; + +/** A gate-cacheable mtime: the real value once stable, else a sentinel that never matches. */ +function cacheable(mtimeMs: number): number { + return Date.now() - mtimeMs > FRESH_MS ? mtimeMs : -1; +} + +/** Absolute path of an indexed shard (reconstructed — rows never store paths; the data root may move). */ +export function traceFilePath(root: string, row: TraceFileRow): string { + const name = `${row.sessionId}_${String(row.fileIndex).padStart(3, "0")}.jsonl`; + return path.join(tracesDir(root, row.projectId, row.agentId), row.date, name); +} + +/** mtimeMs of a path; null when it does not exist. */ +async function statMtime(p: string): Promise { + try { + return (await fs.stat(p)).mtimeMs; + } catch { + return null; + } +} + +async function listDirs(dir: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory()).map((e) => e.name); + } catch { + return []; + } +} + +async function listFiles(dir: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries.filter((e) => e.isFile()).map((e) => e.name); + } catch { + return []; + } +} + +/** Session facts extracted from a shard's records (registration-time classification). */ +function factsFromRecords( + projectId: string, + agentId: string, + sessionId: string, + records: OmniMessage[], +): TraceSessionRow { + const meta = records.find(isSessionMeta); + let firstPrompt: string | null = null; + for (const msg of records) { + if (msg.type !== "model_msg") continue; + const p = msg.payload as { type?: string; role?: string; text?: string }; + if (p.type === "text" && p.role === "user" && typeof p.text === "string") { + firstPrompt = p.text; + break; + } + } + return { + sessionId, + projectId, + agentId, + source: meta ? (asSessionSource(meta.payload.source) ?? null) : null, + workspace: meta && typeof meta.payload.workspace === "string" ? meta.payload.workspace : "", + title: firstPrompt !== null ? fallbackTitle(firstPrompt) : null, + provider: meta && typeof meta.payload.provider === "string" ? meta.payload.provider : null, + modelId: meta && typeof meta.payload.model_id === "string" ? meta.payload.model_id : null, + firstTs: records[0]?.timestamp ?? null, + metaRead: meta !== undefined, + }; +} + +/** In-memory reconcile gate of one Agent: last seen mtimes (traces root + per date dir). */ +interface SeenDirs { + root: number; + dates: Map; +} + +export class TraceIndexService { + private readonly seen = new Map(); + private readonly inflight = new Map>(); + /** + * Test observability (asserting the hot path stays readdir/head-read free): + * gateStats = gate stat calls, dirScans = date-dir readdir passes, headReads = + * registration-time classification reads. + */ + readonly counters = { gateStats: 0, dirScans: 0, headReads: 0 }; + + constructor( + private readonly root: string, + readonly repo: TraceIndexRepo, + /** Shared origin registry: registration-time classification publishes into it (single source of truth for `source`). */ + private readonly sources?: SessionSources, + ) {} + + /** + * Brings one Agent's index in step with disk. Hot path (nothing changed): two stat + * calls, zero readdir. `force` ignores the mtime gate and diffs every date dir (the + * consumers' miss-retry path). + */ + reconcileAgent( + projectId: string, + agentId: string, + opts: { force?: boolean } = {}, + ): Promise { + const key = `${projectId}\0${agentId}`; + const existing = this.inflight.get(key); + if (existing) { + // A forced request must observe disk AFTER the point it was issued: chain a fresh + // pass behind the in-flight one instead of piggybacking on possibly-gated work. + return opts.force === true + ? existing.then(() => this.reconcileAgent(projectId, agentId, opts)) + : existing; + } + const run = this.doReconcile(projectId, agentId, opts.force === true).finally(() => { + this.inflight.delete(key); + }); + this.inflight.set(key, run); + return run; + } + + /** Reconciles every Agent of a Project (the subagent-pointer resolver's miss path). */ + async reconcileProject(projectId: string, opts: { force?: boolean } = {}): Promise { + for (const agentId of await listDirs(agentsDir(this.root, projectId))) { + await this.reconcileAgent(projectId, agentId, opts); + } + } + + private async doReconcile(projectId: string, agentId: string, force: boolean): Promise { + const key = `${projectId}\0${agentId}`; + const dir = tracesDir(this.root, projectId, agentId); + const cached = this.seen.get(key); + this.counters.gateStats += 1; + const rootMtime = await statMtime(dir); + if (rootMtime === null) { + // No traces directory: whatever the index still holds for this Agent is stale. + this.repo.deleteByAgent(projectId, agentId); + this.seen.set(key, { root: -1, dates: new Map() }); + return; + } + if (!force && cached && cached.root === rootMtime) { + // Root unchanged (no date dir created/removed). The one blind spot on this level is + // a new file inside an EXISTING date dir — in practice always the newest one (the + // Writer names dirs by current local date) — so gate on that single dir's mtime too. + // Cached sentinels (-1: the dir was fresh when last seen) never match, forcing a + // re-diff until the tree has been quiet (see FRESH_MS). + const newest = [...cached.dates.keys()].sort().at(-1); + if (newest === undefined) return; + this.counters.gateStats += 1; + const m = await statMtime(path.join(dir, newest)); + if (m !== null && m === cached.dates.get(newest)) return; + } + + // Change detected / first look / force: diff date dirs (readdir only the changed ones). + this.counters.dirScans += 1; + const next: SeenDirs = { root: cacheable(rootMtime), dates: new Map() }; + const knownByDate = new Map(); + for (const row of this.repo.listFilesByAgent(projectId, agentId)) { + const list = knownByDate.get(row.date) ?? []; + list.push(row); + knownByDate.set(row.date, list); + } + const newSessions = new Set(); + const dateDirs = await listDirs(dir); + for (const date of dateDirs) { + const m = await statMtime(path.join(dir, date)); + if (m === null) continue; + next.dates.set(date, cacheable(m)); + // Unchanged dir already reflected in the gate cache: its rows are current. A fresh + // mtime is never treated as unchanged (and was cached as a non-matching sentinel). + if (!force && cached?.dates.get(date) === m) continue; + const known = new Map( + (knownByDate.get(date) ?? []).map((r) => [`${r.sessionId}\0${r.fileIndex}`, r]), + ); + for (const file of await listFiles(path.join(dir, date))) { + const match = TRACE_FILE_RE.exec(file); + if (!match) continue; + const sessionId = match[1]!; + const fileIndex = Number(match[2]); + const fileKey = `${sessionId}\0${fileIndex}`; + let size = 0; + try { + size = (await fs.stat(path.join(dir, date, file))).size; + } catch { + continue; // Vanished between readdir and stat: skip; a later pass settles it. + } + this.repo.upsertFile({ projectId, agentId, sessionId, fileIndex, date, sizeBytes: size }); + known.delete(fileKey); + if (this.repo.getSession(sessionId)?.metaRead !== true) newSessions.add(sessionId); + } + // Rows whose files vanished from this dir (external delete / rename). + for (const row of known.values()) { + this.repo.deleteFile(projectId, agentId, row.sessionId, row.fileIndex); + } + } + // Whole date dirs deleted from disk. + for (const [date, rows] of knownByDate) { + if (next.dates.has(date)) continue; + for (const row of rows) + this.repo.deleteFile(projectId, agentId, row.sessionId, row.fileIndex); + } + // Registration-time classification: ONCE per newly seen Session (bounded head-read + // of its earliest shard) — listings afterwards never touch file contents. + for (const sessionId of newSessions) { + await this.classifySession(projectId, agentId, sessionId); + } + this.seen.set(key, next); + } + + /** Head-reads the Session's earliest indexed shard and stores its facts (origin/workspace/title/model ref). */ + private async classifySession( + projectId: string, + agentId: string, + sessionId: string, + ): Promise { + const files = this.repo.listFilesBySession(projectId, agentId, sessionId); + const earliest = files[0]; + if (earliest === undefined) return; + this.counters.headReads += 1; + let records: OmniMessage[]; + try { + records = await readTraceHead(traceFilePath(this.root, earliest)); + } catch { + records = []; // Unreadable head: facts stay unknown (meta_read=0 → retried by a later reconcile pass). + } + const facts = factsFromRecords(projectId, agentId, sessionId, records); + this.repo.upsertSession(facts); + if (facts.metaRead) this.sources?.set(sessionId, facts.source); + } + + /** + * Write-time registration for the Trace import route: the caller has the file's + * identity AND parsed records in hand, so the row and facts are stored synchronously + * with zero additional IO. + */ + registerImportedFile(args: { + projectId: string; + agentId: string; + sessionId: string; + fileIndex: number; + date: string; + sizeBytes: number; + records: OmniMessage[]; + }): void { + this.repo.upsertFile({ + projectId: args.projectId, + agentId: args.agentId, + sessionId: args.sessionId, + fileIndex: args.fileIndex, + date: args.date, + sizeBytes: args.sizeBytes, + }); + const facts = factsFromRecords(args.projectId, args.agentId, args.sessionId, args.records); + this.repo.upsertSession(facts); + if (facts.metaRead) this.sources?.set(args.sessionId, facts.source); + // The write moved the directory mtimes: drop the gate so the next reconcile re-syncs + // (cheap — the import's own rows are already upserted; the pass just confirms). + this.seen.delete(`${args.projectId}\0${args.agentId}`); + } + + /** Write-time removal for Session deletion (files are being rm'ed by the caller). */ + removeSession(projectId: string, agentId: string, sessionId: string): void { + this.repo.deleteBySession(sessionId); + this.seen.delete(`${projectId}\0${agentId}`); + } + + /** Agent deletion: its whole tree is going away with it. */ + removeAgent(projectId: string, agentId: string): void { + this.repo.deleteByAgent(projectId, agentId); + this.seen.delete(`${projectId}\0${agentId}`); + } + + /** Project deletion. */ + removeProject(projectId: string): void { + this.repo.deleteByProject(projectId); + for (const key of this.seen.keys()) { + if (key.startsWith(`${projectId}\0`)) this.seen.delete(key); + } + } +} diff --git a/packages/server/src/services/trace-service.ts b/packages/server/src/services/trace-service.ts index 97b09de..fa446ac 100644 --- a/packages/server/src/services/trace-service.ts +++ b/packages/server/src/services/trace-service.ts @@ -12,7 +12,6 @@ import fs from "node:fs/promises"; import path from "node:path"; import { - agentsDir, isSessionMeta, parseTraceLines, parseUserSteeringText, @@ -21,8 +20,11 @@ import { } from "@prismshadow/penguin-core"; import type { OmniMessage } from "@prismshadow/penguin-core"; import type { + AgentTraceSessionEntry, AgentTracesResponse, RequestSpan, + SessionCategory, + SessionCategoryCounts, ToolCallSpan, TraceAnalysisResponse, TraceEventsResponse, @@ -33,8 +35,12 @@ import type { TraceToolSpan, UsageTrendPointInTrace, } from "../api/types.js"; +import type { SessionRow } from "../db/repos/sessions.js"; +import type { TraceFileRow, TraceSessionRow } from "../db/repos/trace-index.js"; import { HttpError } from "../http/errors.js"; import { formatLocalDate } from "../internal/dates.js"; +import type { SessionSources } from "../runtime/session-sources.js"; +import { TraceIndexService, traceFilePath } from "./trace-index.js"; const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/; @@ -69,51 +75,68 @@ function subagentPointer(msg: OmniMessage): string | null { return p.session_id; } -async function listDirs(dir: string): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - return entries.filter((e) => e.isDirectory()).map((e) => e.name); - } catch { - return []; - } +/** + * Read-only slice of the Session index the paginated listing consults (SessionsRepo + * structurally satisfies it; injected so service tests need no full app): one indexed + * query answers title / archived / workspace / client for every DB-tracked Session of + * the Agent. + */ +export interface TraceSessionIndex { + listByAgent(projectId: string, agentId: string): SessionRow[]; } -async function listFiles(dir: string): Promise { - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - return entries.filter((e) => e.isFile()).map((e) => e.name); - } catch { - return []; - } +/** TraceService wiring (the trace-file index is required: every listing/locating path serves from it — no directory walks). */ +export interface TraceServiceDeps { + index: TraceIndexService; + /** Optional (narrow tests may omit): DB rows supplying titles / archived / workspace / client. */ + sessions?: TraceSessionIndex; + /** Optional (narrow tests may omit): the shared in-process Session-origin registry (single source of truth for `source`). */ + sources?: SessionSources; +} + +/** One Session's classification result (see classify): its sidebar category + Workspace path ("" = unknown). */ +interface TraceSessionFacts { + category: SessionCategory; + workspace: string; } export class TraceService { - constructor(private readonly root: string) {} + constructor( + private readonly root: string, + private readonly deps: TraceServiceDeps, + ) {} - /** All of this Session's Trace files (sorted by index ascending). */ + /** + * All of this Session's Trace files (sorted by index ascending), served from the + * index. An empty answer force-reconciles once and retries before being believed: + * disk is the source of truth, and a gate blind spot (an external write into an old + * date dir) must cost one extra scan, never a false 404. + */ private async locateAll( projectId: string, agentId: string, sessionId: string, ): Promise { - const dir = tracesDir(this.root, projectId, agentId); - const out: LocatedFile[] = []; - for (const dateDir of await listDirs(dir)) { - for (const file of await listFiles(path.join(dir, dateDir))) { - const match = TRACE_FILE_RE.exec(file); - if (!match || match[1] !== sessionId) continue; - out.push({ path: path.join(dir, dateDir, file), date: dateDir, index: Number(match[2]) }); - } + await this.deps.index.reconcileAgent(projectId, agentId); + let rows = this.deps.index.repo.listFilesBySession(projectId, agentId, sessionId); + if (rows.length === 0) { + await this.deps.index.reconcileAgent(projectId, agentId, { force: true }); + rows = this.deps.index.repo.listFilesBySession(projectId, agentId, sessionId); } - return out.sort((a, b) => a.index - b.index); + return rows.map((r) => ({ + path: traceFilePath(this.root, r), + date: r.date, + index: r.fileIndex, + })); } - /** Deletes all of this Session's Trace files (called when the Session is deleted). */ + /** Deletes all of this Session's Trace files (called when the Session is deleted); the index rows go with them. */ async deleteSessionTraces(projectId: string, agentId: string, sessionId: string): Promise { const files = await this.locateAll(projectId, agentId, sessionId); for (const file of files) { await fs.rm(file.path, { force: true }); } + this.deps.index.removeSession(projectId, agentId, sessionId); } /** @@ -138,38 +161,36 @@ export class TraceService { sessionId: string, ): Promise { return this.readMessagesExpanded(projectId, agentId, sessionId, { - index: null, + projectScanned: false, ancestry: new Set([sessionId]), depth: 0, }); } /** - * A Project-wide session location index (sessionId -> agentId): built by - * scanning every Agent's traces directory. Built lazily the first time a - * subagent pointer is encountered, then reused across the whole readMessages - * call — rescanning per pointer would blow up into tens of thousands of readdir - * calls under multiple sub-sessions plus recursive expansion. + * Locates a subagent pointer's owning Agent within the Project: a single index + * lookup (the old implementation walked EVERY Agent's whole traces tree to build a + * sessionId → agentId map per readMessages call). On a miss, the whole Project is + * force-reconciled ONCE per readMessages call and the lookup retried — a child + * Trace written by an external process is one scan away, never invisible. */ - private async buildSessionIndex(projectId: string): Promise> { - const index = new Map(); - for (const agentId of await listDirs(agentsDir(this.root, projectId))) { - const dir = tracesDir(this.root, projectId, agentId); - for (const dateDir of await listDirs(dir)) { - for (const file of await listFiles(path.join(dir, dateDir))) { - const match = TRACE_FILE_RE.exec(file); - if (match && !index.has(match[1]!)) index.set(match[1]!, agentId); - } - } - } - return index; + private async resolveChildAgent( + projectId: string, + childSid: string, + ctx: { projectScanned: boolean }, + ): Promise { + const hit = this.deps.index.repo.findAgentBySession(projectId, childSid); + if (hit !== null || ctx.projectScanned) return hit; + ctx.projectScanned = true; + await this.deps.index.reconcileProject(projectId, { force: true }); + return this.deps.index.repo.findAgentBySession(projectId, childSid); } private async readMessagesExpanded( projectId: string, agentId: string, sessionId: string, - ctx: { index: Map | null; ancestry: Set; depth: number }, + ctx: { projectScanned: boolean; ancestry: Set; depth: number }, ): Promise { const files = await this.locateAll(projectId, agentId, sessionId); const out: OmniMessage[] = []; @@ -182,8 +203,7 @@ export class TraceService { out.push(msg); continue; } - ctx.index ??= await this.buildSessionIndex(projectId); - const childAgent = ctx.index.get(childSid); + const childAgent = await this.resolveChildAgent(projectId, childSid, ctx); let nested: OmniMessage[] = []; if (childAgent) { ctx.ancestry.add(childSid); @@ -685,35 +705,178 @@ export class TraceService { }; } - /** Level-by-level browsing (newest first): Agent -> date -> Session -> Trace files. */ - async agentTraces(projectId: string, agentId: string): Promise { - const dir = tracesDir(this.root, projectId, agentId); - const dates = (await listDirs(dir)).sort().reverse(); - const out: AgentTracesResponse = { dates: [] }; - for (const date of dates) { - const bySession = new Map(); - for (const file of await listFiles(path.join(dir, date))) { - const match = TRACE_FILE_RE.exec(file); - if (!match) continue; - const sessionId = match[1]!; - const stat = await fs.stat(path.join(dir, date, file)); - const files = bySession.get(sessionId) ?? []; - files.push({ index: Number(match[2]), sizeBytes: stat.size }); - bySession.set(sessionId, files); - } - if (bySession.size === 0) continue; - out.dates.push({ - date, - // session_id embeds a timestamp, so reverse lexicographic order is reverse chronological order. - sessions: [...bySession.entries()] - .sort((a, b) => b[0].localeCompare(a[0])) - .map(([sessionId, files]) => ({ - sessionId, - files: files.sort((a, b) => a.index - b.index), - })), + /** + * Agent-level browsing, served from the trace-file index (one mtime-gated reconcile, + * then pure DB — no directory walks, no head-reads). Without `paging`: the legacy full + * drill-down (newest first: Agent -> date -> Session -> Trace files; sizes are the + * index's last observed values). With `paging`: session-group-centric — Sessions + * ordered by id descending (ids embed a timestamp, so that is reverse chronological), + * optionally filtered to one sidebar `category`, CLI-origin Sessions excluded unless + * `includeCli` (the "show CLI sessions" preference; the legacy shape is never + * filtered — back-compat), and only the returned slice gets per-file `fs.stat` for + * fresh sizes (written back to the index). + */ + async agentTraces( + projectId: string, + agentId: string, + paging?: { offset: number; limit: number } | null, + opts: { category?: SessionCategory; includeCli?: boolean } = {}, + ): Promise { + await this.deps.index.reconcileAgent(projectId, agentId); + const files = this.deps.index.repo.listFilesByAgent(projectId, agentId); + if (paging) return this.agentTracesPage(projectId, agentId, files, paging, opts); + const byDate = new Map>(); + for (const f of files) { + const sessions = + byDate.get(f.date) ?? new Map(); + const list = sessions.get(f.sessionId) ?? []; + list.push({ index: f.fileIndex, sizeBytes: f.sizeBytes }); + sessions.set(f.sessionId, list); + byDate.set(f.date, sessions); + } + return { + dates: [...byDate.keys()] + .sort() + .reverse() + .map((date) => ({ + date, + // session_id embeds a timestamp, so reverse lexicographic order is reverse chronological order. + sessions: [...byDate.get(date)!.entries()] + .sort((a, b) => b[0].localeCompare(a[0])) + .map(([sessionId, list]) => ({ + sessionId, + files: list.sort((a, b) => a.index - b.index), + })), + })), + }; + } + + /** + * Classification (no IO): `archived` comes exactly from the DB row; the origin comes + * from the shared sources registry, else from the Session's registration-time facts + * (trace_sessions — the reconciler head-read its earliest shard once when the file + * first appeared, so by listing time every indexed Session is classified exactly). + */ + private classify( + sessionId: string, + row: SessionRow | undefined, + facts: TraceSessionRow | undefined, + ): TraceSessionFacts { + const known = this.deps.sources?.get(sessionId); + // Registry answer (including null = known user-created) wins — it can be fresher + // (subagent registration happens at spawn, before any reconcile); else the stored facts. + const source = known !== undefined ? known : (facts?.source ?? undefined); + const category: SessionCategory = + (row?.archivedAt ?? null) !== null + ? "archived" + : source === "subagent" || source === "schedule" + ? source + : "active"; + return { category, workspace: row?.workspace ?? facts?.workspace ?? "" }; + } + + /** The paginated listing behind agentTraces: pure index reads; per-file stat only for the returned slice (sizes written back). */ + private async agentTracesPage( + projectId: string, + agentId: string, + files: TraceFileRow[], + paging: { offset: number; limit: number }, + opts: { category?: SessionCategory; includeCli?: boolean }, + ): Promise { + const bySession = new Map(); + for (const f of files) { + const list = bySession.get(f.sessionId) ?? []; + list.push(f); + bySession.set(f.sessionId, list); + } + // One indexed query per source: the Agent's DB rows (title / archived / workspace / + // client — CLI rows included) and the registration-time facts. + const rows = new Map( + (this.deps.sessions?.listByAgent(projectId, agentId) ?? []).map((r) => [r.sessionId, r]), + ); + const factsBySession = new Map( + this.deps.index.repo.listSessionsByAgent(projectId, agentId).map((r) => [r.sessionId, r]), + ); + /** + * CLI-origin = no web-created DB row AND not a subagent/schedule Session. Server-created + * Sessions always have a row (creation / subagent registration insert one), so rowless = + * external CLI; adopted CLI rows carry client='cli'. The default listing excludes these — + * the same "show CLI sessions" preference the sidebar applies — while subagent/schedule + * Sessions stay in their folders regardless of which process ran them. + */ + const cliOrigin = (sessionId: string, facts: TraceSessionFacts): boolean => { + if (facts.category === "subagent" || facts.category === "schedule") return false; + const row = rows.get(sessionId); + return !( + row !== undefined && + (row.client === null || row.client === undefined || row.client === "web") + ); + }; + const ids = [...bySession.keys()].sort((a, b) => b.localeCompare(a)); + // Classify every group once; the same result drives the CLI/category filters, the + // counts AND the returned fields, so a row can never appear in a bucket its own + // `category` denies. Hidden CLI-origin groups are excluded from counts too. + const counts: SessionCategoryCounts = { active: 0, subagent: 0, schedule: 0, archived: 0 }; + const workspaceCounts: Record = {}; + const factsById = new Map(); + const visible: string[] = []; + for (const id of ids) { + const facts = this.classify(id, rows.get(id), factsBySession.get(id)); + if (opts.includeCli !== true && cliOrigin(id, facts)) continue; + factsById.set(id, facts); + visible.push(id); + counts[facts.category] += 1; + const ws = (workspaceCounts[facts.workspace] ??= { + active: 0, + subagent: 0, + schedule: 0, + archived: 0, + }); + ws[facts.category] += 1; + } + const filtered = + opts.category === undefined + ? visible + : visible.filter((id) => factsById.get(id)!.category === opts.category); + const page = filtered.slice(paging.offset, paging.offset + paging.limit); + const sessions: AgentTraceSessionEntry[] = []; + for (const sessionId of page) { + const shard = bySession.get(sessionId)!.sort((a, b) => a.fileIndex - b.fileIndex); + // Fresh sizes for the returned page only (an actively-appended shard grows without + // moving any directory mtime): bounded metadata stats, written back to the index. + const withSize = await Promise.all( + shard.map(async (f) => { + let sizeBytes = f.sizeBytes; + try { + sizeBytes = (await fs.stat(traceFilePath(this.root, f))).size; + if (sizeBytes !== f.sizeBytes) { + this.deps.index.repo.updateFileSize( + projectId, + agentId, + sessionId, + f.fileIndex, + sizeBytes, + ); + } + } catch { + /* Vanished mid-request: serve the last observed size; the next reconcile settles the rows. */ + } + return { index: f.fileIndex, date: f.date, sizeBytes }; + }), + ); + const facts = factsById.get(sessionId)!; + // Title: DB row first (?? sends both "no row" and "row with NULL title" onward); + // else the registration-time first-prompt fallback stored in trace_sessions. + const title = rows.get(sessionId)?.title ?? factsBySession.get(sessionId)?.title ?? undefined; + sessions.push({ + sessionId, + ...(title !== undefined ? { title } : {}), + category: facts.category, + workspace: facts.workspace, + files: withSize, }); } - return out; + return { dates: [], sessions, totalSessions: filtered.length, counts, workspaceCounts }; } private async readFileByIndex( @@ -802,19 +965,28 @@ export class TraceService { 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`); + const body = content.replace(/\n+$/, "") + "\n"; 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", - }); + await fs.writeFile(file, body, { encoding: "utf8", flag: "wx" }); } catch (err) { if ((err as NodeJS.ErrnoException).code === "EEXIST") throw duplicate(); throw err; } + // Write-time registration: this path knows the file's identity and already holds the + // parsed records, so the index row + session facts land synchronously (no re-read). + this.deps.index.registerImportedFile({ + projectId, + agentId, + sessionId, + fileIndex: index, + date, + sizeBytes: Buffer.byteLength(body, "utf8"), + records, + }); return { sessionId, index, date }; } } diff --git a/packages/server/test/agent-trace-detail.test.ts b/packages/server/test/agent-trace-detail.test.ts index 64b04ce..b6b8e66 100644 --- a/packages/server/test/agent-trace-detail.test.ts +++ b/packages/server/test/agent-trace-detail.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { requestBegin, requestEnd, sessionMeta, userText } from "@prismshadow/penguin-core"; import type { SessionMetaPayload } from "@prismshadow/penguin-core"; import type { + AgentTracesResponse, ProjectCreateResponse, TraceAnalysisResponse, TraceEventsResponse, @@ -96,6 +97,86 @@ describe("agent-trace-detail", () => { expect((await owner.get(`${base()}/${UNMANAGED}/9`)).status).toBe(404); }); + it("listing without limit keeps the legacy full shape; with limit it pages Session groups and resolves titles", async () => { + // Legacy request: full date drill-down, no paging fields. + const legacy = (await (await owner.get(base())).json()) as AgentTracesResponse; + expect(legacy.dates.map((d) => d.date)).toEqual(["2026-07-06"]); + expect(legacy.sessions).toBeUndefined(); + expect(legacy.totalSessions).toBeUndefined(); + + // Default paged request: the unmanaged Session is CLI-origin (no web sessions-table + // row, not subagent/schedule), so the "show CLI sessions" OFF default excludes it + // from the listing AND the counts. + const hidden = (await (await owner.get(`${base()}?limit=10`)).json()) as AgentTracesResponse; + expect(hidden.sessions).toEqual([]); + expect(hidden.totalSessions).toBe(0); + expect(hidden.counts).toEqual({ active: 0, subagent: 0, schedule: 0, archived: 0 }); + + // cli=1: visible, with title derived from the first user prompt and category / + // workspace from the registration-time facts (the index head-read the earliest + // shard once when the file was first seen — exact on the FIRST request). + const paged = (await ( + await owner.get(`${base()}?limit=10&cli=1`) + ).json()) as AgentTracesResponse; + expect(paged.totalSessions).toBe(1); + expect(paged.sessions!.map((s) => s.sessionId)).toEqual([UNMANAGED]); + expect(paged.sessions![0]!.title).toBe("child session input"); + expect(paged.sessions![0]!.files.map((f) => ({ index: f.index, date: f.date }))).toEqual([ + { index: 1, date: "2026-07-06" }, + ]); + expect(paged.sessions![0]!.category).toBe("active"); + expect(paged.sessions![0]!.workspace).toBe("/tmp/w"); + expect(paged.counts).toEqual({ active: 1, subagent: 0, schedule: 0, archived: 0 }); + expect(paged.workspaceCounts!["/tmp/w"]).toEqual({ + active: 1, + subagent: 0, + schedule: 0, + archived: 0, + }); + + // A sessions-table title (one batched lookup for the page) wins over the fallback. + t.deps.sessionsRepo.insert({ + sessionId: UNMANAGED, + projectId, + agentId: "default_agent", + provider: "custom", + modelId: "sub-model", + workspace: "/tmp/w", + approvalMode: "allow-all", + title: "已生成的标题", + client: "cli", + createdAt: "2026-07-06T09:00:00.000Z", + }); + const titled = (await ( + await owner.get(`${base()}?limit=10&cli=1`) + ).json()) as AgentTracesResponse; + expect(titled.sessions![0]!.title).toBe("已生成的标题"); + // The adopted row carries client='cli': it stays behind the preference by default. + const stillHidden = (await ( + await owner.get(`${base()}?limit=10`) + ).json()) as AgentTracesResponse; + expect(stillHidden.sessions).toEqual([]); + + // offset pages past the only group; offset without limit is a client error. + const empty = (await ( + await owner.get(`${base()}?limit=10&offset=10&cli=1`) + ).json()) as AgentTracesResponse; + expect(empty.sessions).toEqual([]); + expect(empty.totalSessions).toBe(1); + expect((await owner.get(`${base()}?offset=1`)).status).toBe(400); + + // Category filter: pages within one bucket (totalSessions = the bucket's count); + // unknown values, category-without-limit, and a bad cli value are client errors. + const filtered = (await ( + await owner.get(`${base()}?limit=10&category=subagent&cli=1`) + ).json()) as AgentTracesResponse; + expect(filtered.sessions).toEqual([]); + expect(filtered.totalSessions).toBe(0); + expect((await owner.get(`${base()}?limit=10&category=bogus`)).status).toBe(400); + expect((await owner.get(`${base()}?category=active`)).status).toBe(400); + expect((await owner.get(`${base()}?limit=10&cli=2`)).status).toBe(400); + }); + it("user without access → 404 (requireProjectAccess)", async () => { expect((await outsider.get(`${base()}/${UNMANAGED}/1`)).status).toBe(404); expect((await outsider.get(`${base()}/${UNMANAGED}/1/analysis`)).status).toBe(404); diff --git a/packages/server/test/helpers.ts b/packages/server/test/helpers.ts index 3dd82b5..3155d42 100644 --- a/packages/server/test/helpers.ts +++ b/packages/server/test/helpers.ts @@ -10,6 +10,12 @@ import type { Hono } from "hono"; import type { OmniMessage } from "@prismshadow/penguin-core"; import { buildAppDeps, createApp } from "../src/app.js"; import type { AppDeps, BuildDepsOverrides } from "../src/app.js"; +import { openDatabase } from "../src/db/database.js"; +import { TraceIndexRepo } from "../src/db/repos/trace-index.js"; +import { SessionSources } from "../src/runtime/session-sources.js"; +import { TraceIndexService } from "../src/services/trace-index.js"; +import { TraceService } from "../src/services/trace-service.js"; +import type { TraceSessionIndex } from "../src/services/trace-service.js"; import type { AppEnv } from "../src/auth/middleware.js"; import { ADMIN_USER_ID } from "../src/auth/service.js"; import type { ServerConfig } from "../src/config.js"; @@ -146,6 +152,31 @@ export function apiClient(app: Hono, cookie: string) { }; } +/** + * Index-backed TraceService for pure service tests: an in-memory DB with the real + * schema, a real reconciler over the temp root, and a shared origin registry — the + * same wiring app.ts assembles, minus the HTTP app. + */ +export function makeTraceHarness( + root: string, + opts: { sessions?: TraceSessionIndex; sources?: SessionSources } = {}, +): { + traceIndex: TraceIndexService; + service: TraceService; + sources: SessionSources; + close: () => void; +} { + const db = openDatabase(":memory:"); + const sources = opts.sources ?? new SessionSources(); + const traceIndex = new TraceIndexService(root, new TraceIndexRepo(db), sources); + const service = new TraceService(root, { + index: traceIndex, + ...(opts.sessions !== undefined ? { sessions: opts.sessions } : {}), + sources, + }); + return { traceIndex, service, sources, close: () => db.close() }; +} + /** Writes a Trace JSONL file directly (for building historical / discovery scenarios). */ export async function writeTraceFile( root: string, diff --git a/packages/server/test/trace-index.test.ts b/packages/server/test/trace-index.test.ts new file mode 100644 index 0000000..2672cbd --- /dev/null +++ b/packages/server/test/trace-index.test.ts @@ -0,0 +1,179 @@ +/** + * Trace-file index tests: mtime-gated reconciliation (an unchanged tree costs zero + * directory scans), registration-time classification, gate blind spots recovered by + * the consumers' force-retry, write-time registration (import) and delete coherence. + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { sessionMeta, userText } from "@prismshadow/penguin-core"; +import type { SessionMetaPayload } from "@prismshadow/penguin-core"; +import { makeTempRoot, makeTraceHarness, writeTraceFile } from "./helpers.js"; + +const P = "project-i"; +const A = "agent-i"; +const S1 = "session-2026-07-05-10-00-00-aabb0001"; +const S2 = "session-2026-07-06-09-00-00-aabb0002"; +const OLD = "session-2026-07-01-08-00-00-aabb0003"; + +/** + * Ages the given paths' mtimes past the reconciler's FRESH_MS window: freshly written + * dirs are deliberately never cached as clean (same-tick change hazard), so steady-state + * assertions first make the tree look quiet, exactly like a tree written minutes ago. + */ +async function backdate(...paths: string[]): Promise { + // A FIXED past instant, so repeated backdates are idempotent: the blind-spot test + // resets a changed dir to exactly the mtime the gate cached, which is precisely the + // "old dir changed without moving any gated mtime" scenario. + const old = new Date("2026-01-01T00:00:00.000Z"); + for (const p of paths) { + await fs.utimes(p, old, old); + } +} + +function tracesRoot(root: string): string { + return path.join(root, P, "agents", A, "traces"); +} + +function meta(sessionId: string, over: Partial = {}): SessionMetaPayload { + return { + session_id: sessionId, + model_id: "m1", + provider: "custom", + model_context_window: 1000, + system_prompt: "sp", + tools: [], + agent_state: "/tmp/a", + workspace: "/ws/one", + ...over, + }; +} + +describe("trace-index", () => { + let root: string; + let h: ReturnType; + + beforeEach(async () => { + root = await makeTempRoot(); + h = makeTraceHarness(root); + }); + afterEach(async () => { + h.close(); + await fs.rm(root, { recursive: true, force: true }); + }); + + it("registers external files on reconcile and classifies each Session once; an unchanged tree costs zero directory scans", async () => { + await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [ + sessionMeta(meta(S1, { source: "subagent" })), + userText("你好,请检查代码"), + ]); + await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-05")); + await h.traceIndex.reconcileAgent(P, A); + const rows = h.traceIndex.repo.listFilesByAgent(P, A); + expect( + rows.map((r) => ({ sessionId: r.sessionId, fileIndex: r.fileIndex, date: r.date })), + ).toEqual([{ sessionId: S1, fileIndex: 1, date: "2026-07-05" }]); + expect(rows[0]!.sizeBytes).toBeGreaterThan(0); + // Registration-time facts: origin / workspace / first-prompt title, one head-read. + const facts = h.traceIndex.repo.getSession(S1); + expect(facts?.source).toBe("subagent"); + expect(facts?.workspace).toBe("/ws/one"); + expect(facts?.title).toBe("你好,请检查代码"); + expect(facts?.metaRead).toBe(true); + expect(h.sources.get(S1)).toBe("subagent"); + expect(h.traceIndex.counters.headReads).toBe(1); + + // Steady state: reconciling an unchanged tree is pure gate stats — no readdir, no reads. + const scans = h.traceIndex.counters.dirScans; + await h.traceIndex.reconcileAgent(P, A); + await h.traceIndex.reconcileAgent(P, A); + expect(h.traceIndex.counters.dirScans).toBe(scans); + expect(h.traceIndex.counters.headReads).toBe(1); + + // A new shard in the same (newest) date dir moves that dir's mtime: the gate notices. + await writeTraceFile(root, P, A, "2026-07-05", S2, 1, [ + sessionMeta(meta(S2)), + userText("second session"), + ]); + await h.traceIndex.reconcileAgent(P, A); + expect(h.traceIndex.repo.listFilesByAgent(P, A)).toHaveLength(2); + expect(h.traceIndex.counters.dirScans).toBe(scans + 1); + expect(h.traceIndex.repo.getSession(S2)?.metaRead).toBe(true); + }); + + it("a write into an OLD date dir slips past the gate; the consumers' force-retry (locateAll) recovers it", async () => { + // Two date dirs indexed; the gate then watches the root + the NEWEST dir only. + await writeTraceFile(root, P, A, "2026-07-01", OLD, 1, [userText("old day")]); + await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [userText("new day")]); + await backdate( + tracesRoot(root), + path.join(tracesRoot(root), "2026-07-01"), + path.join(tracesRoot(root), "2026-07-05"), + ); + await h.traceIndex.reconcileAgent(P, A); + expect(h.traceIndex.repo.listFilesByAgent(P, A)).toHaveLength(2); + + // External writer appends a SECOND shard into the old dir: neither gated mtime + // (root / newest dir) moves. Backdating the old dir again keeps its change invisible + // even to a freshness-triggered re-diff — the pure gate blind spot. + await writeTraceFile(root, P, A, "2026-07-01", OLD, 2, [userText("late arrival")]); + await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-01")); + await h.traceIndex.reconcileAgent(P, A); + expect(h.traceIndex.repo.listFilesBySession(P, A, OLD)).toHaveLength(1); // gate blind spot + + // A consumer that misses must force-reconcile and retry rather than 404: the + // service's listTraceFiles goes through locateAll, which does exactly that when the + // requested index is absent... locateAll's whole-session miss is the trigger, so + // exercise it via a session the index does not know at all. + const LATE = "session-2026-07-01-09-00-00-aabb0009"; + await writeTraceFile(root, P, A, "2026-07-01", LATE, 1, [userText("unseen session")]); + await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-01")); + const files = await h.service.listTraceFiles(P, A, LATE); + expect(files.map((f) => f.index)).toEqual([1]); + // The forced pass also swept in the other blind-spot shard. + expect(h.traceIndex.repo.listFilesBySession(P, A, OLD)).toHaveLength(2); + }); + + it("import registers synchronously (rows + facts, no re-read); Session delete removes files and rows", async () => { + const IMP = "session-2026-07-03-12-00-00-aabb0004"; + const lines = [ + sessionMeta(meta(IMP, { source: "schedule", workspace: "/ws/imp" })), + userText("imported prompt"), + ] + .map((m) => JSON.stringify({ ...m, timestamp: "2026-07-03T12:00:00.000Z" })) + .join("\n"); + const headReads = h.traceIndex.counters.headReads; + const res = await h.service.importTraceFile(P, A, lines); + expect(res).toEqual({ sessionId: IMP, index: 1, date: "2026-07-03" }); + // Registered without any head-read (the records were already in memory). + expect(h.traceIndex.counters.headReads).toBe(headReads); + expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toHaveLength(1); + const facts = h.traceIndex.repo.getSession(IMP); + expect(facts?.source).toBe("schedule"); + expect(facts?.workspace).toBe("/ws/imp"); + expect(facts?.title).toBe("imported prompt"); + + await h.service.deleteSessionTraces(P, A, IMP); + expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toEqual([]); + expect(h.traceIndex.repo.getSession(IMP)).toBeNull(); + // The files are gone from disk too, and a fresh reconcile does not resurrect rows. + await h.traceIndex.reconcileAgent(P, A, { force: true }); + expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toEqual([]); + }); + + it("externally deleted files and date dirs fall out of the index on the next changed-dir pass", async () => { + await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [userText("a")]); + await writeTraceFile(root, P, A, "2026-07-05", S1, 2, [userText("b")]); + await h.traceIndex.reconcileAgent(P, A); + expect(h.traceIndex.repo.listFilesBySession(P, A, S1)).toHaveLength(2); + + const dir = path.join(root, P, "agents", A, "traces", "2026-07-05"); + await fs.rm(path.join(dir, `${S1}_002.jsonl`)); + await h.traceIndex.reconcileAgent(P, A); // the dir's mtime moved: rescanned + expect(h.traceIndex.repo.listFilesBySession(P, A, S1).map((r) => r.fileIndex)).toEqual([1]); + + await fs.rm(dir, { recursive: true }); + await h.traceIndex.reconcileAgent(P, A); // the root's mtime moved: date dir purged + expect(h.traceIndex.repo.listFilesByAgent(P, A)).toEqual([]); + }); +}); diff --git a/packages/server/test/trace-service.test.ts b/packages/server/test/trace-service.test.ts index 524af1c..a11a357 100644 --- a/packages/server/test/trace-service.test.ts +++ b/packages/server/test/trace-service.test.ts @@ -23,8 +23,10 @@ import { withOrigin, } from "@prismshadow/penguin-core"; import type { OmniMessage, SessionMetaPayload, TokenCounts } from "@prismshadow/penguin-core"; -import { TraceService } from "../src/services/trace-service.js"; -import { makeTempRoot, writeTraceFile } from "./helpers.js"; +import type { TraceService } from "../src/services/trace-service.js"; +import type { SessionRow } from "../src/db/repos/sessions.js"; +import { SessionSources } from "../src/runtime/session-sources.js"; +import { makeTempRoot, makeTraceHarness, writeTraceFile } from "./helpers.js"; const P = "project-t"; const A = "agent-t"; @@ -48,7 +50,7 @@ function buckets(cacheRead: number, cacheWrite: number, output: number): TokenCo }; } -function metaPayload(): SessionMetaPayload { +function metaPayload(over: Partial = {}): SessionMetaPayload { return { session_id: S, model_id: "m1", @@ -58,18 +60,37 @@ function metaPayload(): SessionMetaPayload { tools: [], agent_state: "/tmp/a", workspace: "/tmp/w", + ...over, + }; +} + +/** A sessions-table row for the paginated listing's index fake (only the listing-relevant fields vary per test). */ +function dbRow(over: Partial & { sessionId: string }): SessionRow { + return { + projectId: P, + agentId: A, + provider: "custom", + modelId: "m1", + workspace: "/tmp/w", + approvalMode: "allow-all", + title: null, + createdAt: "2026-07-05T10:00:00.000Z", + ...over, }; } describe("trace-service", () => { let root: string; let service: TraceService; + let harness: ReturnType; beforeEach(async () => { root = await makeTempRoot(); - service = new TraceService(root); + harness = makeTraceHarness(root); + service = harness.service; }); afterEach(async () => { + harness.close(); await fs.rm(root, { recursive: true, force: true }); }); @@ -530,6 +551,175 @@ describe("trace-service", () => { expect(res.dates.map((d) => d.date)).toEqual(["2026-07-06", "2026-07-05"]); expect(res.dates[1]!.sessions[0]!.sessionId).toBe(S); expect(res.dates[1]!.sessions[0]!.files.map((f) => f.index)).toEqual([1, 2]); + // No `limit` -> the legacy full shape only: the paging fields must stay absent + // (existing consumers see a byte-identical response). + expect(res.sessions).toBeUndefined(); + expect(res.totalSessions).toBeUndefined(); + }); + + it("Agent-level paging: slices Session groups newest-first, merges one Session's files across dates, and carries the total", async () => { + const s2 = "session-2026-07-06-09-00-00-11112222"; + const s3 = "session-2026-07-07-08-00-00-33334444"; + // S spans two date directories: the paged entry must merge them with a per-file date. + await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("a")]); + await writeTraceFile(root, P, A, "2026-07-06", S, 2, [userText("b")]); + await writeTraceFile(root, P, A, "2026-07-06", s2, 1, [userText("c")]); + await writeTraceFile(root, P, A, "2026-07-07", s3, 1, [userText("d")]); + + const page1 = await service.agentTraces(P, A, { offset: 0, limit: 2 }, { includeCli: true }); + expect(page1.totalSessions).toBe(3); + expect(page1.dates).toEqual([]); // paged responses are session-centric; per-file stats happen only for the slice + expect(page1.sessions!.map((s) => s.sessionId)).toEqual([s3, s2]); + + const page2 = await service.agentTraces(P, A, { offset: 2, limit: 2 }, { includeCli: true }); + expect(page2.totalSessions).toBe(3); + expect(page2.sessions!.map((s) => s.sessionId)).toEqual([S]); + expect(page2.sessions![0]!.files.map((f) => ({ index: f.index, date: f.date }))).toEqual([ + { index: 1, date: "2026-07-05" }, + { index: 2, date: "2026-07-06" }, + ]); + expect(page2.sessions![0]!.files.every((f) => f.sizeBytes > 0)).toBe(true); + + // Paging an empty Agent stays well-formed. + const empty = await service.agentTraces( + P, + "agent-none", + { offset: 0, limit: 2 }, + { includeCli: true }, + ); + expect(empty.sessions).toEqual([]); + expect(empty.totalSessions).toBe(0); + }); + + it("Agent-level paging: the sessions DB title wins over the first-prompt fallback", async () => { + const withTitles = makeTraceHarness(root, { + sessions: { listByAgent: () => [dbRow({ sessionId: S, title: "已生成的标题" })] }, + }); + await writeTraceFile(root, P, A, "2026-07-05", S, 1, [ + sessionMeta(metaPayload()), + userText("raw prompt text"), + ]); + const res = await withTitles.service.agentTraces(P, A, { offset: 0, limit: 10 }); + expect(res.sessions![0]!.title).toBe("已生成的标题"); + withTitles.close(); + }); + + it("Agent-level paging: category/workspace come from the DB row (archived wins; a registry-known origin fills its bucket)", async () => { + const s2 = "session-2026-07-06-09-00-00-11112222"; + const s3 = "session-2026-07-07-08-00-00-33334444"; + await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("a")]); + await writeTraceFile(root, P, A, "2026-07-06", s2, 1, [userText("b")]); + await writeTraceFile(root, P, A, "2026-07-07", s3, 1, [userText("c")]); + const sources = new SessionSources(); + sources.set(s2, "subagent"); + sources.set(s3, null); // meta seen, user-created + const h = makeTraceHarness(root, { + sources, + sessions: { + listByAgent: () => [ + dbRow({ sessionId: S, workspace: "/ws/one", archivedAt: "2026-07-08T00:00:00.000Z" }), + dbRow({ sessionId: s3, workspace: "/ws/two" }), + ], + }, + }); + + const res = await h.service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true }); + const byId = new Map(res.sessions!.map((x) => [x.sessionId, x])); + expect(byId.get(S)!.category).toBe("archived"); + expect(byId.get(S)!.workspace).toBe("/ws/one"); + expect(byId.get(s2)!.category).toBe("subagent"); // untracked but registry-known + expect(byId.get(s3)!.category).toBe("active"); + expect(res.counts).toEqual({ active: 1, subagent: 1, schedule: 0, archived: 1 }); + expect(res.workspaceCounts!["/ws/one"]).toEqual({ + active: 0, + subagent: 0, + schedule: 0, + archived: 1, + }); + expect(res.workspaceCounts!["/ws/two"]).toEqual({ + active: 1, + subagent: 0, + schedule: 0, + archived: 0, + }); + + // The category filter pages within one bucket; totalSessions is the bucket's count. + const active = await h.service.agentTraces( + P, + A, + { offset: 0, limit: 10 }, + { category: "active", includeCli: true }, + ); + expect(active.sessions!.map((x) => x.sessionId)).toEqual([s3]); + expect(active.totalSessions).toBe(1); + expect(active.counts).toEqual({ active: 1, subagent: 1, schedule: 0, archived: 1 }); + const archived = await h.service.agentTraces( + P, + A, + { offset: 0, limit: 10 }, + { category: "archived", includeCli: true }, + ); + expect(archived.sessions!.map((x) => x.sessionId)).toEqual([S]); + h.close(); + }); + + it("Agent-level paging: an untracked Session is classified at registration (head-read once), so the FIRST listing is exact", async () => { + // No DB row, nothing pre-registered: the reconciler registers the file and reads the + // earliest shard's session_meta ONCE; the listing itself then serves category / + // workspace / title from the index with no per-request head-reads. + await writeTraceFile(root, P, A, "2026-07-05", S, 1, [ + sessionMeta(metaPayload({ source: "subagent", workspace: "/ws/child" })), + userText("child prompt"), + ]); + + const first = await service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true }); + expect(first.sessions![0]!.category).toBe("subagent"); + expect(first.sessions![0]!.workspace).toBe("/ws/child"); + expect(first.counts).toEqual({ active: 0, subagent: 1, schedule: 0, archived: 0 }); + // The observation landed in the shared registry (single source of truth) at registration. + expect(harness.sources.get(S)).toBe("subagent"); + const reads = harness.traceIndex.counters.headReads; + + const second = await service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true }); + expect(second.sessions![0]!.category).toBe("subagent"); + expect(harness.traceIndex.counters.headReads).toBe(reads); // classification never re-reads + }); + + it("Agent-level paging: CLI-origin Sessions are hidden by default and shown with includeCli (subagent/schedule stay visible)", async () => { + const cliSid = "session-2026-07-06-09-00-00-11112222"; // untracked, user-created meta -> CLI-origin + const childSid = "session-2026-07-07-08-00-00-33334444"; // untracked but subagent-origin -> its folder, regardless + await writeTraceFile(root, P, A, "2026-07-06", cliSid, 1, [ + sessionMeta(metaPayload({ session_id: cliSid })), + userText("cli run"), + ]); + await writeTraceFile(root, P, A, "2026-07-07", childSid, 1, [ + sessionMeta(metaPayload({ session_id: childSid, source: "subagent" })), + userText("child run"), + ]); + const webSid = S; + await writeTraceFile(root, P, A, "2026-07-05", webSid, 1, [ + sessionMeta(metaPayload()), + userText("web run"), + ]); + const h = makeTraceHarness(root, { + sessions: { listByAgent: () => [dbRow({ sessionId: webSid, workspace: "/ws/web" })] }, + }); + + const hidden = await h.service.agentTraces(P, A, { offset: 0, limit: 10 }); + expect(hidden.sessions!.map((x) => x.sessionId)).toEqual([childSid, webSid]); + expect(hidden.totalSessions).toBe(2); + expect(hidden.counts).toEqual({ active: 1, subagent: 1, schedule: 0, archived: 0 }); + + const shown = await h.service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true }); + expect(shown.sessions!.map((x) => x.sessionId)).toEqual([childSid, cliSid, webSid]); + expect(shown.counts).toEqual({ active: 2, subagent: 1, schedule: 0, archived: 0 }); + h.close(); + }); + + it("Agent-level paging: a Session whose head has no user text gets no title (the client falls back to its default)", async () => { + await writeTraceFile(root, P, A, "2026-07-05", S, 1, [sessionMeta(metaPayload())]); + const res = await service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true }); + expect(res.sessions![0]!.title).toBeUndefined(); }); it("every endpoint returns empty when there is no Trace", async () => { diff --git a/packages/server/test/trace-subagent-expand.test.ts b/packages/server/test/trace-subagent-expand.test.ts index d61a56a..f1d8d91 100644 --- a/packages/server/test/trace-subagent-expand.test.ts +++ b/packages/server/test/trace-subagent-expand.test.ts @@ -22,7 +22,8 @@ import { userText, } from "@prismshadow/penguin-core"; import type { OmniMessage, SessionMetaPayload } from "@prismshadow/penguin-core"; -import { TraceService } from "../src/services/trace-service.js"; +import type { TraceService } from "../src/services/trace-service.js"; +import { makeTraceHarness } from "./helpers.js"; const PROJECT = "proj"; const PARENT_AGENT = "default_agent"; @@ -63,12 +64,15 @@ async function writeTrace( describe("TraceService.readMessages — sub-session expansion", () => { let root: string; let svc: TraceService; + let harness: ReturnType; beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-trace-expand-")); - svc = new TraceService(root); + harness = makeTraceHarness(root); + svc = harness.service; }); afterEach(async () => { + harness.close(); await fs.rm(root, { recursive: true, force: true }); }); diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index 3116ce7..fc6ebce 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -228,9 +228,28 @@ export const resetAgentConfig = (projectId: string, agentId: string) => { method: "POST" }, ); -export const getAgentTraces = (projectId: string, agentId: string) => +/** + * Optional paging (absent = the legacy full date-grouped response): pages Session groups + * newest-first; a paged response answers with `sessions` (titles + category/workspace), + * `totalSessions`, and per-category `counts` / `workspaceCounts`. `category` filters to + * one sidebar bucket (paging applies within it, mirroring the sessions list); `cli` + * includes CLI-origin Sessions (the "show CLI sessions" preference, default off — same + * parameter convention as listSessions). The Trace page requests `limit+1` per page to + * detect "has more" (splitPage). + */ +export const getAgentTraces = ( + projectId: string, + agentId: string, + paging?: { offset: number; limit: number; category?: SessionCategory; cli?: boolean }, +) => apiFetch( - `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/traces`, + `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/traces${ + paging + ? `?limit=${paging.limit}&offset=${paging.offset}` + + (paging.category ? `&category=${paging.category}` : "") + + (paging.cli ? "&cli=1" : "") + : "" + }`, ); // Session --------------------------------------------------------------------- diff --git a/packages/web/src/components/layout/app-layout.tsx b/packages/web/src/components/layout/app-layout.tsx index 0f55c79..320625c 100644 --- a/packages/web/src/components/layout/app-layout.tsx +++ b/packages/web/src/components/layout/app-layout.tsx @@ -13,7 +13,8 @@ import { useProject } from "../../state/project"; import { useSessions } from "../../state/sessions"; import { Drawer } from "../ui/drawer"; import { GlyphIcon } from "../ui/glyph-icon"; -import { NAV_ICONS, NEW_CHAT_ICON, Sidebar } from "./sidebar"; +import { NAV_ICONS } from "../ui/icons"; +import { NEW_CHAT_ICON, Sidebar } from "./sidebar"; import { DRAFT_SESSION_ID } from "../../features/chat/chat-page"; import { ChangePasswordDialog } from "../account/change-password-dialog"; diff --git a/packages/web/src/components/layout/sidebar.tsx b/packages/web/src/components/layout/sidebar.tsx index 1c19fbc..9ea4615 100644 --- a/packages/web/src/components/layout/sidebar.tsx +++ b/packages/web/src/components/layout/sidebar.tsx @@ -49,8 +49,19 @@ import type { FolderCategory, SessionPartition } from "../../lib/session-groupin import { Switch } from "../ui/switch"; import { Dropdown } from "../ui/dropdown"; import { AgentAvatar } from "../ui/agent-avatar"; -import { Chevron } from "../ui/chevron"; -import { ChevronDown } from "../ui/icons"; +import { ChevronDown, NAV_ICONS } from "../ui/icons"; +import { + FOLDER_ICON, + FOLDER_OPEN_ICON, + FolderSection, + GroupHeader, + GroupModeToggle, + Icon, + MoreRow, + initialGroupMode, + storeGroupMode, +} from "../ui/group-list"; +import type { GroupMode } from "../ui/group-list"; import { toastError, toastInfo, toastSuccess } from "../ui/toast"; import { Truncated } from "../ui/truncated"; import { Badge } from "../ui/badge"; @@ -67,37 +78,6 @@ import { ChangePasswordDialog } from "../account/change-password-dialog"; import { UpdateDialog } from "../account/update-dialog"; import { forceUpdateCheck, updateCheckOutcome, useVersionInfo } from "../../lib/use-version-info"; -function Icon({ d, size = 16 }: { d: string; size?: number }) { - return ( - - - - ); -} - -/** Page-nav glyphs (shared with the collapsed rail in app-layout.tsx). */ -export const NAV_ICONS = { - agents: "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01", - /** Skill library (an open book: two pages + spine). */ - skills: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z", - models: "M7 7h10v10H7zM4 10h3m10 0h3M4 14h3m10 0h3M10 4v3m4-3v3m-4 10v3m4-3v3", - usage: "M4 20V10m6 10V4m6 16v-7m4 7H2", - traces: "M4 6h16M4 12h10M4 18h13", - /** Benchmark center (a trophy: cup + two handles + base). */ - benchmark: - "M7 4h10v5a5 5 0 0 1-10 0V4zM7 5H4v1a3 3 0 0 0 3 3m10-4h3v1a3 3 0 0 1-3 3M12 14v4m-4 0h8", -} as const; - /** New-chat pencil (the pinned "New chat" button and the collapsed rail share it). */ export const NEW_CHAT_ICON = "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"; @@ -105,13 +85,6 @@ export const NEW_CHAT_ICON = "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L const GEAR_ICON = "M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"; -/** Folder outline, closed (same glyph as the draft page's Workspace pill); collapsed workspace groups and the grouping toggle use it. */ -const FOLDER_ICON = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"; - -/** Folder outline, open (lucide folder-open: back panel + tilted front flap); expanded workspace groups use it. */ -const FOLDER_OPEN_ICON = - "m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"; - /** Pushpin (lucide pin: head + body + stem), the group-header pin toggle / pinned indicator. */ const PIN_ICON = "M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z"; @@ -119,13 +92,6 @@ const PIN_ICON = const menuItemClass = "block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"; -/** Grouping mode of the Session list (persisted; Workspace is the default). */ -type GroupMode = "workspace" | "agent"; -const GROUP_MODE_KEY = "penguin.sidebarGroupMode"; -function initialGroupMode(): GroupMode { - return localStorage.getItem(GROUP_MODE_KEY) === "agent" ? "agent" : "workspace"; -} - /** * Collapsed-group and pinned-group persistence (survives a refresh), one storage key * per Project and concern — group keys are Agent ids / Workspace paths, which are @@ -305,7 +271,7 @@ export function Sidebar({ const [renameError, setRenameError] = useState(null); const setGroupMode = (mode: GroupMode) => { - localStorage.setItem(GROUP_MODE_KEY, mode); + storeGroupMode(mode); setGroupModeState(mode); // The two modes have unrelated group lists: restart the reveal window. setGroupCap(SIDEBAR_GROUP_PAGE_SIZE); @@ -543,9 +509,6 @@ export function Sidebar({ ); - const folderClass = - "flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50"; - /** * Collapsed-by-default lazy folder (subagent / scheduled / archived): nothing is * fetched until the first expand, and once open the folder pages independently with @@ -553,7 +516,11 @@ export function Sidebar({ * (`totals` — the Agent's counts in agent mode, the per-Workspace fold in workspace * mode): the folder exists only while its share is non-zero, the label shows that * share, and "More" shows only while loaded rows fall short of it — an Agent's - * content in *other* Workspaces can never surface a folder here. + * content in *other* Workspaces can never surface a folder here. The folder's "More" + * pages independently of the active list's; in workspace mode a fetched page can land + * rows in other groups' folders too, so one click may grow this folder by fewer than + * a full page — the row shows a loading state while the fetch runs and stays until + * this group's share is fully loaded. */ const renderFolder = ( groupKey: string, @@ -568,39 +535,21 @@ export function Sidebar({ // Loaded rows win a disagreement with the totals (counts refresh only on reload). const total = Math.max(totals?.[category] ?? 0, rows.length); if (total === 0) return null; - const open = openFolders.has(folderKey(groupKey, category)); // More while the group's share isn't fully loaded AND somewhere is left to fetch from // (counts drifting above reality would otherwise leave a dead button until reload). const more = rows.length < total && agentIds.some((id) => hasMoreFor(id, category)); - const pending = pendingLoads.has(loadKey(groupKey, category)); return ( -
- - {open && renderRows(rows, withAgentHint)} - {/* The folder's own paging, independent of the active list's "More". In workspace - mode a fetched page can land rows in other groups' folders too, so one click may - grow this folder by fewer than a full page — the row shows a loading state while - the fetch runs and stays until this group's share is fully loaded. */} - {open && more && ( - - )} -
+ toggleFolder(groupKey, category, agentIds)} + more={more} + pending={pendingLoads.has(loadKey(groupKey, category))} + onMore={() => trackedLoadMore(groupKey, category, agentIds)} + > + {renderRows(rows, withAgentHint)} + ); }; @@ -654,16 +603,12 @@ export function Sidebar({ {/* Load/reveal more (kept adjacent to the active list it extends, above the folders) */} {hasMore && ( - + className="mt-0.5" + /> )} {/* Folders (collapsed by default): subagent first — spawned from the conversations @@ -676,14 +621,11 @@ export function Sidebar({ /** Reveal-next-page-of-groups row (render cap only — data loading is untouched). */ const moreGroupsRow = (total: number) => ( - + className="mt-1" + /> ); const navItems: Array<{ to: string; label: string; icon: string }> = [ @@ -860,30 +802,7 @@ export function Sidebar({ {S.chat.sessionList} -
- {( - [ - { value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace }, - { value: "agent", icon: NAV_ICONS.agents, label: S.chat.groupByAgent }, - ] as const - ).map((opt) => ( - - ))} -
+ {groupMode === "agent" ? ( @@ -896,50 +815,44 @@ export function Sidebar({ const pinned = pinnedGroups.has(agent.agentId); return (
- {/* Group header: collapse toggle (Agent name) + pin + new chat + Agent settings. - self-stretch makes the collapse toggle's hover pill span the full row height - set by the h-7 action buttons (one consistent hover geometry). */} -
- - togglePin(agent.agentId)} /> - {/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */} - - -
+ } + label={agentDisplayName(agent)} + uppercase + actions={ + <> + togglePin(agent.agentId)} /> + {/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */} + + + + } + /> {collapsed ? null @@ -977,46 +890,38 @@ export function Sidebar({ ]; return (
- {/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + pin + new chat in this Workspace. - self-stretch: without it the collapse toggle's hover pill is content-sized - (~20px) and sits visibly shorter than the h-7 action buttons beside it. */} -
- - togglePin(group.key)} /> - {/* New chat in this Workspace: pre-fills the group's path in the draft ("" = auto temp directory); the Agent is the current one, falling back to default_agent */} - -
+ } + label={group.temp ? S.chat.tempWorkspaces : group.label} + count={Math.max(counts?.totals.active ?? 0, parts.active.length)} + {...(group.fullPath !== null ? { title: group.fullPath } : {})} + actions={ + <> + togglePin(group.key)} /> + {/* New chat in this Workspace: pre-fills the group's path in the draft ("" = auto temp directory); the Agent is the current one, falling back to default_agent */} + + + } + /> {/* A workspace group can span Agents: the group body fans folder loads and "More" out per category to the Agents whose share of THIS group is non-zero (plus the diff --git a/packages/web/src/components/ui/group-list.tsx b/packages/web/src/components/ui/group-list.tsx new file mode 100644 index 0000000..7d4f1ad --- /dev/null +++ b/packages/web/src/components/ui/group-list.tsx @@ -0,0 +1,230 @@ +/** + * Shared building blocks of the grouped Session lists (the chat sidebar and the Trace + * page's directory tree render the same structure — group header, collapsed lazy + * folders, "More" rows, the Workspace/Agent grouping toggle). Extracted verbatim from + * sidebar.tsx's inner closures so the two surfaces cannot drift apart visually: the + * markup and classes here ARE the sidebar's; callers pass the state the closures used + * to capture. + */ +import type { ReactNode } from "react"; +import { S } from "../../lib/strings"; +import { Chevron } from "./chevron"; + +/** Minimal stroke-icon wrapper shared by the grouped lists (moved from sidebar.tsx). */ +export function Icon({ d, size = 16 }: { d: string; size?: number }) { + return ( + + + + ); +} + +/** Folder outline, closed (same glyph as the draft page's Workspace pill); collapsed workspace groups and the grouping toggle use it. */ +export const FOLDER_ICON = + "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"; + +/** Folder outline, open (lucide folder-open: back panel + tilted front flap); expanded workspace groups use it. */ +export const FOLDER_OPEN_ICON = + "m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"; + +/** Agent glyph (the grouping toggle's "by Agent" option; also NAV_ICONS.agents in the sidebar nav). */ +export const AGENT_GROUP_ICON = + "M12 3v3m-6 4a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5zm3 3h.01M15 13h.01"; + +/** Grouping mode of a Session list (persisted; Workspace is the default). */ +export type GroupMode = "workspace" | "agent"; + +/** + * One storage key for every grouped-list surface (sidebar + Trace page): the grouping + * choice is a single user preference, not a per-page one — switching it anywhere + * switches it everywhere. + */ +const GROUP_MODE_KEY = "penguin.sidebarGroupMode"; + +export function initialGroupMode(): GroupMode { + return localStorage.getItem(GROUP_MODE_KEY) === "agent" ? "agent" : "workspace"; +} + +export function storeGroupMode(mode: GroupMode): void { + localStorage.setItem(GROUP_MODE_KEY, mode); +} + +/** The two-icon Workspace/Agent grouping toggle (the section header's mode switch). */ +export function GroupModeToggle({ + value, + onChange, +}: { + value: GroupMode; + onChange: (mode: GroupMode) => void; +}) { + return ( +
+ {( + [ + { value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace }, + { value: "agent", icon: AGENT_GROUP_ICON, label: S.chat.groupByAgent }, + ] as const + ).map((opt) => ( + + ))} +
+ ); +} + +/** Row class of folder toggles and "More" rows (the sidebar's folderClass). */ +export const FOLDER_ROW_CLASS = + "flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50"; + +/** + * "More"-style row (a group's load-next-page, a folder's paging, the reveal-more-groups + * cap): folder-row styling with the chevron column left blank. While `pending` the row + * disables and reads the shared loading label. + */ +export function MoreRow({ + label, + pending = false, + onClick, + className, +}: { + label: string; + /** A fetch is in flight: disable and show the loading label. */ + pending?: boolean; + onClick: () => void; + /** Extra spacing classes (the active list's row adds mt-0.5, the groups row mt-1). */ + className?: string; +}) { + return ( + + ); +} + +/** + * Collapsed-by-default lazy folder (subagent / scheduled / archived): the toggle row + * shows the label (typically with the group's exact server share), the body renders only + * while open, and an optional "More" row pages the folder independently. + */ +export function FolderSection({ + label, + open, + onToggle, + more = false, + pending = false, + onMore, + children, +}: { + label: string; + open: boolean; + onToggle: () => void; + /** Show the folder's own "More" row (its share isn't fully loaded and somewhere is left to fetch from). */ + more?: boolean; + /** The folder's "More" fetch in flight. */ + pending?: boolean; + onMore?: () => void; + children?: ReactNode; +}) { + return ( +
+ + {open && children} + {open && more && ( + onMore?.()} /> + )} +
+ ); +} + +/** + * Group header row: the collapse toggle (leading icon + label + optional count + + * chevron) stretching across, with optional action buttons trailing outside it. The + * toggle's hover pill spans the full row height set by any h-7 actions (self-stretch — + * see the sidebar's header comments for where this first bit). + */ +export function GroupHeader({ + open, + onToggle, + icon, + label, + uppercase = false, + count, + title, + actions, +}: { + open: boolean; + onToggle: () => void; + /** Leading visual (Agent avatar / folder icon), sized by the caller. */ + icon: ReactNode; + label: string; + /** Agent names render uppercase-tracked (sidebar convention); a directory basename's casing is meaningful, so workspace groups don't. */ + uppercase?: boolean; + /** Optional trailing count (workspace groups: the group's active total). */ + count?: number; + /** Optional tooltip (workspace groups: the full path). */ + title?: string; + /** Trailing header actions (pin / new chat / settings / import). */ + actions?: ReactNode; +}) { + return ( +
+ + {actions} +
+ ); +} diff --git a/packages/web/src/components/ui/icons.tsx b/packages/web/src/components/ui/icons.tsx index cf17805..ebf4a32 100644 --- a/packages/web/src/components/ui/icons.tsx +++ b/packages/web/src/components/ui/icons.tsx @@ -5,6 +5,7 @@ */ import type { ButtonHTMLAttributes } from "react"; import { S } from "../../lib/strings"; +import { AGENT_GROUP_ICON } from "./group-list"; /** Downward caret on Select / OptionMenu / composer dropdown triggers. Color follows currentColor (callers add text-gray-400). */ export function ChevronDown({ size = 12, className = "" }: { size?: number; className?: string }) { @@ -141,3 +142,21 @@ export function CloseButton({ ); } + +/** + * Page-nav glyphs (moved from sidebar.tsx: the sidebar nav, the collapsed rail in + * app-layout.tsx, and cross-page jump actions — e.g. the chat info dropdown's "view + * trace" — share them; living here keeps chat-page free of a sidebar import cycle, + * sidebar.tsx importing DRAFT_SESSION_ID from chat-page). + */ +export const NAV_ICONS = { + agents: AGENT_GROUP_ICON, + /** Skill library (an open book: two pages + spine). */ + skills: "M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z", + models: "M7 7h10v10H7zM4 10h3m10 0h3M4 14h3m10 0h3M10 4v3m4-3v3m-4 10v3m4-3v3", + usage: "M4 20V10m6 10V4m6 16v-7m4 7H2", + traces: "M4 6h16M4 12h10M4 18h13", + /** Benchmark center (a trophy: cup + two handles + base). */ + benchmark: + "M7 4h10v5a5 5 0 0 1-10 0V4zM7 5H4v1a3 3 0 0 0 3 3m10-4h3v1a3 3 0 0 1-3 3M12 14v4m-4 0h8", +} as const; diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index 7d1f400..19e0989 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -51,6 +51,7 @@ import { Skeleton } from "../../components/ui/skeleton"; import { Truncated } from "../../components/ui/truncated"; import { Dropdown } from "../../components/ui/dropdown"; import { EmptyState } from "../../components/ui/empty-state"; +import { NAV_ICONS } from "../../components/ui/icons"; import { toastError } from "../../components/ui/toast"; import { MessageStream } from "./message-stream"; import type { StreamRenderContext } from "./message-stream"; @@ -1157,6 +1158,39 @@ export function ChatPage() {

+ {/* Jump to this Session's Trace: SPA-navigates to the Trace page deep-linked to + the owning Agent AND this Session (?agentId= focuses/expands the Agent group, + ?sessionId= auto-selects — a Session beyond the first loaded page resolves via + the Trace page's full-fetch fallback). Only reachable for a real Session: this + whole header renders behind the `selected` guard, so a draft never shows it. */} +
+ +
)} diff --git a/packages/web/src/features/traces/trace-sessions.ts b/packages/web/src/features/traces/trace-sessions.ts new file mode 100644 index 0000000..073969b --- /dev/null +++ b/packages/web/src/features/traces/trace-sessions.ts @@ -0,0 +1,104 @@ +/** + * Pure Session-list logic for the Trace page (kept out of the components so it is unit + * testable): mapping either server response shape to the page's Session groups, and + * partitioning rows by their server-classified category. + */ +import type { AgentTracesResponse, SessionCategory } from "@prismshadow/penguin-server/api"; + +/** + * Trace page sessions fetched per (group, category) page, and the per-group display cap + * step for active rows — the Trace-tree counterpart of the sidebar's SIDEBAR_PAGE_SIZE, + * deliberately larger: the sidebar is a narrow column, while the Trace tree is a + * full-height page column, so 10-row pages felt stingy there. Fetches use + * limit = TRACES_PAGE_SIZE + 1 (splitPage) exactly like the sidebar; folder pages use + * the same size, so every "More" on this page steps uniformly. + */ +export const TRACES_PAGE_SIZE = 20; + +/** + * Groups (Agents / Workspace groups) rendered per Trace page "page" (the sidebar's + * SIDEBAR_GROUP_PAGE_SIZE counterpart, modestly larger for the same reason as above); + * "more groups" raises it by one page — a pure display cap, data loading untouched. + */ +export const TRACES_GROUP_PAGE_SIZE = 15; + +export interface TraceFileRef { + index: number; + date: string; + sizeBytes: number; +} + +export interface TraceSessionGroup { + sessionId: string; + /** Server-resolved display title (DB title or first-prompt fallback); absent for legacy responses / untitled Sessions. */ + title?: string; + /** + * Server-classified sidebar category (the paged endpoint's bounded classification — + * the same value its filter and counts used, so rows and buckets can't disagree). + * Legacy responses carry no classification; the mapper defaults to "active". + */ + category: SessionCategory; + /** Workspace path ("" = unknown → the merged temp group, same defensive rule as the sidebar's isTempWorkspace). */ + workspace: string; + /** Sorted newest first (a higher index is newer) — the page's display order. */ + files: TraceFileRef[]; +} + +/** A pooled row of the Trace tree: the group plus its owning Agent (the fetch that produced it). */ +export interface TraceSessionRow extends TraceSessionGroup { + agentId: string; +} + +/** + * Maps a listing response to Session groups, newest Session first. A paged response + * (`sessions` present) is already session-centric and server-ordered; a legacy full + * response is flattened from its date grouping (merging one Session's files across + * dates) and sorted by sessionId descending — ids embed a timestamp, so that is + * reverse chronological, matching the server's paged ordering. + */ +export function toSessionGroups(data: AgentTracesResponse): TraceSessionGroup[] { + if (data.sessions) { + return data.sessions.map((s) => ({ + sessionId: s.sessionId, + ...(s.title !== undefined ? { title: s.title } : {}), + category: s.category, + workspace: s.workspace, + files: [...s.files].sort((a, b) => b.index - a.index), + })); + } + const bySession = new Map(); + for (const d of data.dates) { + for (const s of d.sessions) { + const list = bySession.get(s.sessionId) ?? []; + for (const f of s.files) list.push({ index: f.index, date: d.date, sizeBytes: f.sizeBytes }); + bySession.set(s.sessionId, list); + } + } + return [...bySession.entries()] + .sort((a, b) => b[0].localeCompare(a[0])) + .map(([sessionId, files]) => ({ + sessionId, + category: "active" as const, + workspace: "", + files: files.sort((a, b) => b.index - a.index), + })); +} + +/** + * Partition of one group's rows by their server-classified category — the Trace-side + * twin of session-grouping's partitionSessions (which derives the category client-side + * from SessionInfo; here the server field is authoritative, because it is what the + * per-category fetches and counts were computed from). + */ +export function partitionTraceRows( + rows: readonly T[], +): Record { + const parts: Record = { + active: [], + subagent: [], + schedule: [], + archived: [], + }; + for (const r of rows) parts[r.category].push(r); + return parts; +} diff --git a/packages/web/src/features/traces/traces-page.tsx b/packages/web/src/features/traces/traces-page.tsx index fb70b95..c0a911e 100644 --- a/packages/web/src/features/traces/traces-page.tsx +++ b/packages/web/src/features/traces/traces-page.tsx @@ -1,24 +1,51 @@ /** - * Trace browsing page: the left-side directory lists Sessions grouped only by - * Agent (titles come from the Sessions context, with unmanaged CLI/subagent - * Sessions falling back to sessionId); the right side shows the selected - * Session's Trace files (paged, most recent first by default) + performance - * analysis (an execution timeline) + an event timeline. + * Trace browsing page. The left-side directory mirrors the chat sidebar's structure on + * the shared grouped-list building blocks (components/ui/group-list.tsx): the same two + * grouping modes behind the same persisted preference (by Workspace — the default — or + * by Agent), group bodies split into the active list plus the collapsed lazy + * subagent / scheduled / archived folders, and the same "More" paging rows. Data comes + * from the paginated traces endpoint (paged per (Agent, category), see use-trace-tree), + * honoring the same "show CLI sessions" preference as the sidebar (default off; + * subagent/schedule Sessions stay in their folders regardless of origin). + * Titles are server-resolved (DB title or first-prompt fallback) with the Sessions + * store overriding when it has a fresher one; raw session ids never render. The right + * side shows the selected Session's Trace files (paged, most recent first by default) + + * performance analysis (an execution timeline) + an event timeline. */ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent } from "react"; import { useSearchParams } from "react-router"; -import type { AgentTracesResponse } from "@prismshadow/penguin-server/api"; +import type { SessionCategory, SessionCategoryCounts } from "@prismshadow/penguin-server/api"; import * as api from "../../api/endpoints"; import { S } from "../../lib/strings"; import { apiErrorText } from "../../lib/api-error"; import { useDocumentTitle } from "../../lib/use-document-title"; import { formatBytes } from "../../lib/format"; +import { + FOLDER_CATEGORIES, + aggregateWorkspaceCounts, + groupSessionsByWorkspace, + pinnedFirst, +} from "../../lib/session-grouping"; +import type { FolderCategory } from "../../lib/session-grouping"; import { agentDisplayName, useProject } from "../../state/project"; import { AgentAvatar } from "../../components/ui/agent-avatar"; -import { Chevron } from "../../components/ui/chevron"; +import { + FOLDER_ICON, + FOLDER_OPEN_ICON, + FolderSection, + GroupHeader, + GroupModeToggle, + Icon, + MoreRow, + initialGroupMode, + storeGroupMode, +} from "../../components/ui/group-list"; +import type { GroupMode } from "../../components/ui/group-list"; import { HiddenFileInput } from "../../components/ui/hidden-file-input"; import { DownloadIcon, UploadIcon } from "../../components/ui/icons"; +import { Modal } from "../../components/ui/modal"; +import { Select } from "../../components/ui/select"; import { toastError } from "../../components/ui/toast"; import { Truncated } from "../../components/ui/truncated"; import { useSessions } from "../../state/sessions"; @@ -26,6 +53,14 @@ import { EmptyState } from "../../components/ui/empty-state"; import { SkeletonList } from "../../components/ui/skeleton"; import { TraceFileView } from "./trace-file-view"; import type { TraceHighlight } from "./timeline-chart"; +import { + TRACES_GROUP_PAGE_SIZE, + TRACES_PAGE_SIZE, + partitionTraceRows, + toSessionGroups, +} from "./trace-sessions"; +import type { TraceFileRef, TraceSessionRow } from "./trace-sessions"; +import { useTraceTree } from "./use-trace-tree"; /** * Import file size cap, mirroring the server's route-side limit (agent-traces.ts). @@ -35,243 +70,91 @@ import type { TraceHighlight } from "./timeline-chart"; */ const MAX_TRACE_BYTES = 14 * 1024 * 1024; -interface TraceFileRef { - index: number; - date: string; - sizeBytes: number; -} +/** File pills rendered before the "+N" overflow control expands them (a long Session can hold dozens of compaction shards). */ +const FILE_PILL_CAP = 10; -interface SessionGroup { - sessionId: string; - files: TraceFileRef[]; -} +/** Open-state key of a collapsed folder inside a group ("\0" never appears in Agent ids or Workspace paths — the sidebar's composite). */ +const folderKey = (groupKey: string, category: FolderCategory) => `${category}\0${groupKey}`; interface Selection { /** Details go through the Agent-level endpoint (not dependent on the sessions table's tracking), so the owning Agent must be carried along. */ agentId: string; sessionId: string; + /** Server-resolved title of the selected group (the header still prefers a fresher store title). */ + title?: string; files: TraceFileRef[]; } -/** Flatten by Session (merging a Session's files across dates); Sessions are sorted by id descending = newest first. */ -function flattenSessions(data: AgentTracesResponse): SessionGroup[] { - const bySession = new Map(); - for (const d of data.dates) { - for (const s of d.sessions) { - const list = bySession.get(s.sessionId) ?? []; - for (const f of s.files) list.push({ index: f.index, date: d.date, sizeBytes: f.sizeBytes }); - bySession.set(s.sessionId, list); - } - } - return [...bySession.entries()] - .sort((a, b) => b[0].localeCompare(a[0])) - .map(([sessionId, files]) => ({ - sessionId, - // Files sorted newest first by default (a higher index is newer). - files: files.sort((a, b) => b.index - a.index), - })); -} - -/** A single Agent's expandable tree node (traces are fetched only when expanded; titles are mapped via the Sessions context). */ -function AgentNode({ - projectId, - agentId, - name, - defaultOpen, - focusSessionId, - canImport, - titleOf, - selection, - onSelect, -}: { - projectId: string; - agentId: string; - name: string; - /** Initial expanded state: all expanded when there's no deep link; only the target Agent expanded when there's an ?agentId= deep link. */ - 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; -}) { - 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; - api - .getAgentTraces(projectId, agentId) - .then((data) => setGroups(flattenSessions(data))) - .catch((e: unknown) => setError(apiErrorText(e))); - }, [open, groups, projectId, agentId]); - - // The Session deep link is applied only once: it selects the target as - // soon as the list is first ready (if not found, it just stays in the - // list state without erroring); after that, the user's manual switches - // are never pulled back by the deep-link parameter. - const focusApplied = useRef(false); - useEffect(() => { - if (focusApplied.current || !focusSessionId || !groups) return; - focusApplied.current = true; - const target = groups.find((g) => g.sessionId === focusSessionId); - 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. - return ( -
  • -
    - - {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}

    - )} - {groups && groups.length === 0 && ( -

    {S.traces.empty}

    - )} -
      - {groups?.map((g) => { - const active = selection?.agentId === agentId && selection.sessionId === g.sessionId; - const title = titleOf(agentId, g.sessionId); - return ( -
    • - -
    • - ); - })} -
    -
    - )} -
  • - ); -} - export function TracesPage() { useDocumentTitle(S.traces.title); const { currentProject, agents, agentsLoading } = useProject(); - const { byAgent } = useSessions(); + // showCliSessions: the traces tree follows the same per-user "show CLI sessions" + // preference as the sidebar (default off) — CLI-origin Sessions are excluded + // server-side from the listing, counts and workspace groups until it is enabled. + // A deep link to a CLI Session still resolves while the preference is off: the + // selection fallback (selectFromFull) uses the unfiltered legacy endpoint, so the + // Session opens in the right pane without appearing in the tree. + const { byAgent, showCliSessions } = useSessions(); const projectId = currentProject?.projectId ?? null; - // ?agentId= deep link (from the Agents page's "traces" entry point): only - // the target Agent defaults to expanded, the rest collapse to keep focus on it. + const tree = useTraceTree(projectId, showCliSessions); + // ?agentId= deep link (from the Agents page's "traces" entry point): forces agent + // grouping for this visit (the target must be a visible, expanded group) WITHOUT + // overwriting the stored preference; only the target Agent defaults to expanded. const [searchParams] = useSearchParams(); const focusAgentId = searchParams.get("agentId"); // ?sessionId= deep link (jumped to directly from the evaluation center's // runs): auto-selects once the target Agent's Session list is ready. const focusSessionId = searchParams.get("sessionId"); + // Grouping mode: the same persisted preference as the chat sidebar + // (penguin.sidebarGroupMode) so the two surfaces always group alike. + const [modePref, setModePref] = useState(initialGroupMode); + const groupMode: GroupMode = focusAgentId !== null ? "agent" : modePref; const [selection, setSelection] = useState(null); const [fileIndex, setFileIndex] = useState(null); + /** Rendered groups cap (the sidebar's group paging pattern: raised a page per "more groups" click; reset per Project and on a mode switch). */ + const [groupCap, setGroupCap] = useState(TRACES_GROUP_PAGE_SIZE); + /** Per-group display cap for active rows (keyed by group key; absent = TRACES_PAGE_SIZE). "More" raises it a page at a time. */ + const [groupCaps, setGroupCaps] = useState>(new Map()); + /** + * Agent-mode open groups; null = the default (only the deep-link target / first + * Agent expands — expanding all of them fanned out one directory scan per Agent). + * Materialized on the first manual toggle. + */ + const [openAgentGroups, setOpenAgentGroups] = useState | null>(null); + /** Workspace-mode collapsed groups (expanded by default, like the sidebar — their data is already pooled). */ + const [collapsedWsGroups, setCollapsedWsGroups] = useState>(new Set()); + /** Expanded folders (subagent / scheduled / archived; collapsed by default), keyed by folderKey — each folder has its own open state. */ + const [openFolders, setOpenFolders] = useState>(new Set()); + /** "More" rows with a fetch in flight, keyed `${category}\0${groupKey}` (disable + loading text). */ + const [pendingLoads, setPendingLoads] = useState>(new Set()); + /** Import dialog open state and its target Agent (the endpoint is per-Agent, so the page-level button asks which one). */ + const [importOpen, setImportOpen] = useState(false); + const [importAgentId, setImportAgentId] = useState(""); + /** Agent whose Trace import is in flight (the header button disables and reads "importing"). */ + const [importingAgent, setImportingAgent] = useState(null); + /** File-pill overflow expanded for the current selection. */ + const [showAllFiles, setShowAllFiles] = useState(false); // Linked highlighting between the trace observation view and the event list (keyed by tool_call_id). const [highlight, setHighlight] = useState(null); + /** Session to auto-select once the refreshed list arrives (the import response's sessionId). */ + const importedSession = useRef<{ agentId: string; sessionId: string } | null>(null); - // Clear the selection when switching Project. + const setGroupMode = (mode: GroupMode) => { + storeGroupMode(mode); + setModePref(mode); + // The two modes have unrelated group lists: restart the reveal window. + setGroupCap(TRACES_GROUP_PAGE_SIZE); + }; + + // Clear the selection and per-Project view state when switching Project. useEffect(() => { setSelection(null); setFileIndex(null); + setGroupCap(TRACES_GROUP_PAGE_SIZE); + setGroupCaps(new Map()); + setOpenAgentGroups(null); + setCollapsedWsGroups(new Set()); + setOpenFolders(new Set()); }, [projectId]); // Clear the linked highlight when switching Session / Trace file. @@ -279,8 +162,372 @@ export function TracesPage() { setHighlight(null); }, [selection, fileIndex]); - const titleOf = (agentId: string, sessionId: string): string | undefined => - byAgent.get(agentId)?.find((s) => s.sessionId === sessionId)?.title; + // Collapse the file-pill overflow when switching Session (picking a pill must not re-collapse the row it lives in). + useEffect(() => { + setShowAllFiles(false); + }, [selection]); + + /** + * sessionId -> freshest store title (live session_title pushes land in the Sessions + * store first). Memoized Map: a per-row `.find` would re-scan the store list on + * every render of every row. + */ + const storeTitles = useMemo(() => { + const m = new Map(); + for (const list of byAgent.values()) { + for (const s of list) if (s.title !== undefined) m.set(s.sessionId, s.title); + } + return m; + }, [byAgent]); + + /** Display title: store title (freshest) -> server-resolved title -> default. Raw session ids never render as text. */ + const titleOf = useCallback( + (sessionId: string, serverTitle?: string): string => + storeTitles.get(sessionId) ?? serverTitle ?? S.chat.defaultSessionTitle, + [storeTitles], + ); + + /** agentId → display name (row hint tooltips in workspace mode). */ + const agentNameById = useMemo( + () => new Map(agents.map((a) => [a.agentId, agentDisplayName(a)])), + [agents], + ); + + // The ?agentId= focus target is pinned first so the deep link stays visible above the + // group cap (the sidebar's pinnedFirst, with the deep link as the pinned set). + const orderedAgents = useMemo( + () => + pinnedFirst(agents, (a) => a.agentId, new Set(focusAgentId === null ? [] : [focusAgentId])), + [agents, focusAgentId], + ); + + /** Agent-mode effective open set: the deep-link target / first Agent until the user toggles. */ + const effectiveOpenAgents = useMemo(() => { + if (openAgentGroups !== null) return openAgentGroups; + const first = focusAgentId ?? orderedAgents[0]?.agentId; + return new Set(first === undefined ? [] : [first]); + }, [openAgentGroups, focusAgentId, orderedAgents]); + + const toggleAgentGroup = (agentId: string) => { + const next = new Set(effectiveOpenAgents); + if (next.has(agentId)) next.delete(agentId); + else next.add(agentId); + setOpenAgentGroups(next); + }; + + const toggleWsGroup = (key: string) => { + setCollapsedWsGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + // Agent mode loads lazily: only rendered-and-expanded groups fetch their first page. + const ensureFirstFor = tree.ensureFirstFor; + useEffect(() => { + if (!projectId || groupMode !== "agent") return; + const visible = orderedAgents + .slice(0, groupCap) + .map((a) => a.agentId) + .filter((id) => effectiveOpenAgents.has(id)); + if (visible.length > 0) ensureFirstFor(visible); + }, [projectId, groupMode, orderedAgents, groupCap, effectiveOpenAgents, ensureFirstFor]); + + // Workspace mode needs every Agent's first active page to group by Workspace at all: + // fetched in parallel (one paged request per Agent), like the sidebar's reload fans + // out over the sessions API. + useEffect(() => { + if (!projectId || groupMode !== "workspace" || agents.length === 0) return; + ensureFirstFor(agents.map((a) => a.agentId)); + }, [projectId, groupMode, agents, ensureFirstFor]); + + /** Workspace groups from the pooled rows; the sessionId doubles as the chronological sort key groupSessionsByWorkspace needs (ids embed the creation timestamp). */ + const workspaceGroups = useMemo( + () => groupSessionsByWorkspace(tree.allRows.map((r) => ({ ...r, createdAt: r.sessionId }))), + [tree.allRows], + ); + /** Workspace-mode per-group exact server totals (folded from the per-Agent per-Workspace counts). */ + const workspaceGroupCounts = useMemo( + () => aggregateWorkspaceCounts(tree.workspaceCountsByAgent), + [tree.workspaceCountsByAgent], + ); + + const select = useCallback((row: TraceSessionRow) => { + setSelection({ + agentId: row.agentId, + sessionId: row.sessionId, + ...(row.title !== undefined ? { title: row.title } : {}), + files: row.files, + }); + setFileIndex(row.files[0]?.index ?? null); + }, []); + + /** + * Selects a Session that isn't in the loaded pages (a deep link to, or re-import of, + * an old Session): one legacy full fetch resolves its files. That is the pre-paging + * cost of the whole tree, but for a single Agent and only on this path; a miss stays + * silent, like an in-list miss. + */ + const selectFromFull = useCallback( + (agentId: string, sessionId: string) => { + if (!projectId) return; + api + .getAgentTraces(projectId, agentId) + .then((data) => { + const target = toSessionGroups(data).find((g) => g.sessionId === sessionId); + if (target) select({ ...target, agentId }); + }) + .catch(() => {}); + }, + [projectId, select], + ); + + // The Session deep link is applied only once: it selects the target as soon as the + // target Agent's first page is ready (falling back to a full fetch when the target + // sorts beyond it or lives in a folder category); after that, the user's manual + // switches are never pulled back by the deep-link parameter. + const focusApplied = useRef(false); + useEffect(() => { + if (focusApplied.current || !focusSessionId || !focusAgentId) return; + if (!tree.isLoadedFor(focusAgentId, "active")) return; + focusApplied.current = true; + const target = tree.rowsByAgent.get(focusAgentId)?.find((g) => g.sessionId === focusSessionId); + if (target) select(target); + else selectFromFull(focusAgentId, focusSessionId); + }, [tree, focusSessionId, focusAgentId, select, selectFromFull]); + + // 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. A re-imported OLD session id can sort beyond + // the refreshed first page; the full-fetch fallback still selects it. + useEffect(() => { + const pendingImport = importedSession.current; + if (pendingImport === null) return; + const rows = tree.rowsByAgent.get(pendingImport.agentId); + if (rows === undefined) return; + importedSession.current = null; + const target = rows.find((g) => g.sessionId === pendingImport.sessionId); + if (target) select(target); + else selectFromFull(pendingImport.agentId, pendingImport.sessionId); + }, [tree, select, selectFromFull]); + + const runImport = async (agentId: string, dataBase64: string) => { + if (!projectId) return; + setImportingAgent(agentId); + try { + const res = await api.importAgentTrace(projectId, agentId, { dataBase64 }); + // Refresh the Agent's pages; the effect watching the tree then jumps to the + // imported Session. The group also expands so the selection is visible. + importedSession.current = { agentId, sessionId: res.sessionId }; + if (!effectiveOpenAgents.has(agentId)) toggleAgentGroup(agentId); + await tree.refreshAgent(agentId); + } 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 inline load error). + toastError(apiErrorText(e)); + } finally { + setImportingAgent(null); + } + }; + + const onPickFile = (e: ChangeEvent) => { + const agentId = importAgentId; + const file = e.target.files?.[0]; + // Reset before reading so re-picking the same file fires change again. + e.target.value = ""; + if (!file || agentId === "") return; + if (file.size > MAX_TRACE_BYTES) { + toastError(S.traces.fileTooLarge); + return; + } + // Picking a file IS the confirmation: close the dialog and run the import (the + // header button shows the in-flight state; failures toast as before). + setImportOpen(false); + const reader = new FileReader(); + reader.onload = () => { + const url = reader.result as string; + void runImport(agentId, url.slice(url.indexOf(",") + 1)); // strip the data:...;base64, prefix + }; + reader.onerror = () => toastError(S.common.unknownError); + reader.readAsDataURL(file); + }; + + /** Opens the import dialog, defaulting the target to the deep-linked / first Agent. */ + const openImport = () => { + setImportAgentId(focusAgentId ?? orderedAgents[0]?.agentId ?? ""); + setImportOpen(true); + }; + + /** In-flight key of one group's category "More" (folderKey shares the same composite for folder categories). */ + const loadKey = (groupKey: string, category: SessionCategory) => `${category}\0${groupKey}`; + + /** loadMoreFor with an in-flight marker for the triggering "More" row (disable + loading text). */ + const trackedLoadMore = (groupKey: string, category: SessionCategory, agentIds: string[]) => { + const key = loadKey(groupKey, category); + setPendingLoads((prev) => new Set(prev).add(key)); + void tree.loadMoreFor(agentIds, category).finally(() => { + setPendingLoads((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + }); + }; + + /** + * Open/close a group's folder. A folder's content is loaded on demand: the first + * expand fetches its category's first page for every contributing Agent that hasn't + * been asked yet (already-loaded rows stay put — re-expanding never refetches; the + * folder's own "More" row does the paging from there). + */ + const toggleFolder = (groupKey: string, category: FolderCategory, agentIds: string[]) => { + const key = folderKey(groupKey, category); + const opening = !openFolders.has(key); + setOpenFolders((prev) => { + const next = new Set(prev); + if (opening) next.add(key); + else next.delete(key); + return next; + }); + if (opening) { + const unloaded = agentIds.filter((id) => !tree.isLoadedFor(id, category)); + if (unloaded.length > 0) void tree.loadMoreFor(unloaded, category); + } + }; + + /** Session rows shared by both modes; withAgentHint adds a small Agent avatar per row (workspace mode, where the group no longer names the Agent). */ + const renderRows = (rows: TraceSessionRow[], withAgentHint: boolean) => ( +
      + {rows.map((r) => { + const active = selection?.sessionId === r.sessionId; + const hint = agentNameById.get(r.agentId) ?? r.agentId; + return ( +
    • + +
    • + ); + })} +
    + ); + + /** + * Lazy folder inside a group (the sidebar's renderFolder shape): exists only while + * the group's own exact server share is non-zero, the label shows that share, and + * "More" shows only while loaded rows fall short of it. + */ + const renderFolder = ( + groupKey: string, + category: FolderCategory, + parts: Record, + withAgentHint: boolean, + agentIds: string[], + totals: SessionCategoryCounts | undefined, + ) => { + const rows = parts[category]; + // Loaded rows win a disagreement with the totals (counts refresh on the next fetch). + const total = Math.max(totals?.[category] ?? 0, rows.length); + if (total === 0) return null; + const more = rows.length < total && agentIds.some((id) => tree.hasMoreFor(id, category)); + return ( + toggleFolder(groupKey, category, agentIds)} + more={more} + pending={pendingLoads.has(loadKey(groupKey, category))} + onMore={() => trackedLoadMore(groupKey, category, agentIds)} + > + {renderRows(rows, withAgentHint)} + + ); + }; + + /** Active-list "More": reveal one more page of already-loaded active rows AND fetch the next active server page for every Agent that still has one. */ + const showMore = (groupKey: string, agentIds: string[]) => { + setGroupCaps((prev) => { + const next = new Map(prev); + next.set(groupKey, (prev.get(groupKey) ?? TRACES_PAGE_SIZE) + TRACES_PAGE_SIZE); + return next; + }); + if (agentIds.length > 0) trackedLoadMore(groupKey, "active", agentIds); + }; + + /** + * Expanded group body shared by both modes (the sidebar's renderGroupBody shape): + * active rows (display-capped; "More" reveals and loads further active-only pages — + * the folders below never feed it) + the collapsed-by-default subagent / scheduled / + * archived folders, each loading on first expand and paging on its own. Rows are + * partitioned by their server-classified category (partitionTraceRows). + */ + const renderGroupBody = ( + groupKey: string, + rows: TraceSessionRow[], + withAgentHint: boolean, + totals: SessionCategoryCounts | undefined, + agentsFor: (category: SessionCategory) => string[], + emptyText: string, + ) => { + const parts = partitionTraceRows(rows); + const cap = groupCaps.get(groupKey) ?? TRACES_PAGE_SIZE; + const shownActive = parts.active.slice(0, cap); + const activeAgents = agentsFor("active"); + const activeTotal = Math.max(totals?.active ?? 0, parts.active.length); + const hasMore = + parts.active.length > cap || + (parts.active.length < activeTotal && + activeAgents.some((id) => tree.hasMoreFor(id, "active"))); + const folders = FOLDER_CATEGORIES.map((category) => + renderFolder(groupKey, category, parts, withAgentHint, agentsFor(category), totals), + ); + const empty = parts.active.length === 0 && folders.every((f) => f === null); + const activePending = pendingLoads.has(loadKey(groupKey, "active")); + return ( + <> + {empty ? ( +

    {emptyText}

    + ) : ( + renderRows(shownActive, withAgentHint) + )} + {/* Load/reveal more (kept adjacent to the active list it extends, above the folders) */} + {hasMore && ( + showMore(groupKey, activeAgents)} + className="mt-0.5" + /> + )} + {folders} + + ); + }; if (!projectId) return null; @@ -288,42 +535,156 @@ export function TracesPage() { selection === null ? null : (selection.files.find((f) => f.index === fileIndex) ?? selection.files[0] ?? null); + const visibleFiles = + selection === null || showAllFiles ? null : selection.files.slice(0, FILE_PILL_CAP); + + /** Workspace mode is still fanning out its first pages and has nothing pooled yet. */ + const wsLoading = + tree.allRows.length === 0 && agents.some((a) => tree.isPendingFor(a.agentId, "active")); return (
    - {/* Directory tree: Agent → Session title (≥md left column; -

    - {S.traces.title} -

    - {agentsLoading ? ( +
    +

    + {S.traces.title} +

    +
    + {/* Page-level Trace import (owner-only; moved here from the per-Agent group + headers): one button beside the grouping toggle, matching its control + height; the dialog then asks which Agent receives the file. */} + {currentProject?.role === "owner" && ( + + )} + {/* Hidden while an ?agentId= deep link forces agent grouping: a toggle showing + the (possibly different) stored preference would look broken. */} + {focusAgentId === null && } +
    +
    + {agentsLoading || (groupMode === "workspace" && wsLoading) ? ( - ) : ( -
      - {agents.map((a) => ( - { - setSelection(sel); - setFileIndex(sel.files[0]?.index ?? null); - }} + ) : groupMode === "agent" ? ( + <> + {orderedAgents.slice(0, groupCap).map((a) => { + const open = effectiveOpenAgents.has(a.agentId); + const name = agentDisplayName(a); + const error = tree.errorByAgent.get(a.agentId); + const loaded = tree.isLoadedFor(a.agentId, "active"); + return ( +
      + toggleAgentGroup(a.agentId)} + icon={ + + } + label={name} + uppercase + /> + {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 !== undefined && ( +

      {error}

      + )} + {!loaded && error === undefined && ( +

      {S.common.loading}

      + )} + {loaded && + renderGroupBody( + a.agentId, + tree.rowsByAgent.get(a.agentId) ?? [], + false, + tree.countsByAgent.get(a.agentId), + () => [a.agentId], + S.traces.empty, + )} +
      + )} +
      + ); + })} + {orderedAgents.length > groupCap && ( + setGroupCap((c) => c + TRACES_GROUP_PAGE_SIZE)} + className="mt-1" /> - ))} -
    + )} + + ) : workspaceGroups.length === 0 ? ( +

    + {S.chat.noSessions} +

    + ) : ( + <> + {workspaceGroups.slice(0, groupCap).map((group) => { + const collapsed = collapsedWsGroups.has(group.key); + const parts = partitionTraceRows(group.sessions); + /** This group's exact server share (per-Workspace fold) and its per-category fetch fan-out. */ + const counts = workspaceGroupCounts.get(group.key); + const contributingAgents = [...new Set(group.sessions.map((s) => s.agentId))]; + const agentsFor = (category: SessionCategory) => [ + ...new Set([...(counts?.agents[category] ?? []), ...contributingAgents]), + ]; + return ( +
    + toggleWsGroup(group.key)} + icon={ + /* Folder opens and closes with the group */ + + + + } + label={group.temp ? S.chat.tempWorkspaces : group.label} + count={Math.max(counts?.totals.active ?? 0, parts.active.length)} + {...(group.fullPath !== null ? { title: group.fullPath } : {})} + /> + {/* A workspace group can span Agents: the group body fans folder loads and + "More" out per category to the Agents whose share of THIS group is + non-zero (plus the Agents already contributing loaded rows). */} + {collapsed + ? null + : renderGroupBody( + group.key, + group.sessions, + true, + counts?.totals, + agentsFor, + S.chat.noSessions, + )} +
    + ); + })} + {workspaceGroups.length > groupCap && ( + setGroupCap((c) => c + TRACES_GROUP_PAGE_SIZE)} + className="mt-1" + /> + )} + )} @@ -333,15 +694,11 @@ export function TracesPage() { {/* Header: Session title + Trace file pagination (newest first) */}

    - {titleOf(selection.agentId, selection.sessionId) ?? ( - - {selection.sessionId} - - )} + {titleOf(selection.sessionId, selection.title)}

    {S.traces.filesTitle} - {selection.files.map((f) => ( + {(visibleFiles ?? selection.files).map((f) => ( + )}
    + {/* Date · size only — the session id stays in the data (selection, deep links) but never renders as text. */}

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

    {/* Raw-file download for the selected Trace file (same styling as the inactive file pills). */} )} + + {/* Import dialog: the endpoint is per-Agent (the file's own session_meta cannot + name a local Agent — its agent_state path belongs to the exporting machine), + so the page-level button asks which Agent receives the file. Picking a file + confirms and starts the import; everything downstream (validation, 409 on a + duplicate session id, refresh + auto-select of the imported Session) is the + unchanged runImport flow. */} + setImportOpen(false)}> +
    + +
    + {/* File pick doubles as the confirm action (primary-button styling on a label + so the native picker opens without a detour). */} + +
    +
    +
    ); } diff --git a/packages/web/src/features/traces/use-trace-tree.ts b/packages/web/src/features/traces/use-trace-tree.ts new file mode 100644 index 0000000..8cb3a50 --- /dev/null +++ b/packages/web/src/features/traces/use-trace-tree.ts @@ -0,0 +1,324 @@ +/** + * Data layer of the Trace page's directory tree — the traces-endpoint twin of the + * sessions store (state/sessions.tsx): paged per (Agent, category) against + * GET .../traces with limit+1 pages (splitPage), pooling rows by sessionId. Later + * fetches overwrite pooled rows: the server's bounded classification refines as its + * head-read caches warm up, and an overwrite moves a row into its true category + * bucket. Per-category totals and per-Workspace counts ride along on every paged + * response (no separate counts request). + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { SessionCategory, SessionCategoryCounts } from "@prismshadow/penguin-server/api"; +import * as api from "../../api/endpoints"; +import { apiErrorText } from "../../lib/api-error"; +import { FOLDER_CATEGORIES, splitPage } from "../../lib/session-grouping"; +import { TRACES_PAGE_SIZE, toSessionGroups } from "./trace-sessions"; +import type { TraceSessionRow } from "./trace-sessions"; + +/** Page-state key of one (Agent, category) pair ("\0" never appears in Agent ids). */ +const pairKey = (agentId: string, category: SessionCategory) => `${agentId}\0${category}`; + +/** One pair's paging cursor (same convention as the sessions store: `fetched` counts rows consumed from the server's category stream). */ +interface PagePosition { + hasMore: boolean; + fetched: number; +} + +/** One fetched page, ready to merge. */ +interface FetchedPage { + agentId: string; + category: SessionCategory; + items: TraceSessionRow[]; + hasMore: boolean; + counts?: SessionCategoryCounts; + workspaceCounts?: Readonly>; +} + +export interface TraceTree { + /** agentId → its pooled rows (every fetched category), newest first by sessionId. */ + rowsByAgent: ReadonlyMap; + /** All pooled rows (workspace-mode grouping input). */ + allRows: TraceSessionRow[]; + /** agentId → per-category totals over ALL of the Agent's Trace session groups. */ + countsByAgent: ReadonlyMap; + /** agentId → the same totals broken down by Workspace path (workspace-mode group headers). */ + workspaceCountsByAgent: ReadonlyMap>>; + /** agentId → active-first-page load failure (inline tree error, mirroring the load-state-with-content rule). */ + errorByAgent: ReadonlyMap; + isLoadedFor: (agentId: string, category: SessionCategory) => boolean; + /** Whether the pair's active-first-page fetch is in flight (skeleton/loading states). */ + isPendingFor: (agentId: string, category: SessionCategory) => boolean; + /** Whether the server still holds unfetched groups of a category — an unloaded pair answers from the counts. */ + hasMoreFor: (agentId: string, category: SessionCategory) => boolean; + /** Fetches the active first page (+ counts) for each given Agent that has none yet (idempotent; safe to call from render effects). */ + ensureFirstFor: (agentIds: string[]) => void; + /** Fetches a category's first page for each given unloaded Agent (skipped unless its counts hold anything) and the next page for each loaded one with more. */ + loadMoreFor: (agentIds: string[], category: SessionCategory) => Promise; + /** Drops and refetches one Agent's loaded pairs (post-import refresh), keeping open folders populated. */ + refreshAgent: (agentId: string) => Promise; +} + +/** + * @param includeCli Mirror of the user's "show CLI sessions" preference: appended to + * every paged fetch (`cli=1`); flipping it resets and refetches the whole tree under + * the new filter, like the sessions store does. + */ +export function useTraceTree(projectId: string | null, includeCli = false): TraceTree { + /** sessionId → pooled row (the newest fetch wins — see module doc). */ + const [rows, setRows] = useState>(new Map()); + const [pageState, setPageState] = useState>(new Map()); + const [countsByAgent, setCountsByAgent] = useState>( + new Map(), + ); + const [workspaceCountsByAgent, setWorkspaceCountsByAgent] = useState< + ReadonlyMap>> + >(new Map()); + const [errorByAgent, setErrorByAgent] = useState>(new Map()); + const [pending, setPending] = useState>(new Set()); + // Generation counter: a Project switch invalidates in-flight responses. + const gen = useRef(0); + // Synchronous in-flight guard: ensureFirstFor runs from render effects, which can + // double-fire (StrictMode) before the async state update lands. + const inflight = useRef(new Set()); + const pageStateRef = useRef(pageState); + pageStateRef.current = pageState; + const countsRef = useRef(countsByAgent); + countsRef.current = countsByAgent; + + // Project switch / CLI-preference flip: drop everything (rows / cursors / counts are + // Project- and filter-scoped) — consumers' ensure effects then refetch under the new state. + useEffect(() => { + gen.current += 1; + inflight.current.clear(); + const emptyPages = new Map(); + pageStateRef.current = emptyPages; + setRows(new Map()); + setPageState(emptyPages); + setCountsByAgent(new Map()); + setWorkspaceCountsByAgent(new Map()); + setErrorByAgent(new Map()); + setPending(new Set()); + }, [projectId, includeCli]); + + const fetchPage = useCallback( + async ( + pid: string, + agentId: string, + category: SessionCategory, + offset: number, + ): Promise => { + const res = await api.getAgentTraces(pid, agentId, { + offset, + limit: TRACES_PAGE_SIZE + 1, + category, + ...(includeCli ? { cli: true } : {}), + }); + const page = splitPage(toSessionGroups(res), TRACES_PAGE_SIZE); + return { + agentId, + category, + items: page.items.map((g) => ({ ...g, agentId })), + hasMore: page.hasMore, + ...(res.counts !== undefined ? { counts: res.counts } : {}), + ...(res.workspaceCounts !== undefined ? { workspaceCounts: res.workspaceCounts } : {}), + }; + }, + [includeCli], + ); + + /** Merges fetched pages: rows overwrite by sessionId, cursors advance, counts refresh to the newest response. */ + const applyPages = useCallback((pages: FetchedPage[], resetPairsOf?: string) => { + setRows((prev) => { + const next = new Map(prev); + if (resetPairsOf !== undefined) { + for (const [sid, row] of prev) if (row.agentId === resetPairsOf) next.delete(sid); + } + for (const p of pages) for (const row of p.items) next.set(row.sessionId, row); + return next; + }); + setPageState((prev) => { + const next = new Map(prev); + if (resetPairsOf !== undefined) { + for (const key of prev.keys()) if (key.startsWith(`${resetPairsOf}\0`)) next.delete(key); + } + for (const p of pages) { + const key = pairKey(p.agentId, p.category); + next.set(key, { + hasMore: p.hasMore, + fetched: + (resetPairsOf === undefined ? (prev.get(key)?.fetched ?? 0) : 0) + p.items.length, + }); + } + return next; + }); + for (const p of pages) { + if (p.counts) { + setCountsByAgent((prev) => new Map(prev).set(p.agentId, p.counts!)); + } + if (p.workspaceCounts) { + setWorkspaceCountsByAgent((prev) => new Map(prev).set(p.agentId, p.workspaceCounts!)); + } + } + }, []); + + const ensureFirstFor = useCallback( + (agentIds: string[]) => { + if (!projectId) return; + const targets = [...new Set(agentIds)].filter((agentId) => { + const key = pairKey(agentId, "active"); + return !pageStateRef.current.has(key) && !inflight.current.has(key); + }); + if (targets.length === 0) return; + const g = gen.current; + for (const agentId of targets) inflight.current.add(pairKey(agentId, "active")); + setPending((prev) => { + const next = new Set(prev); + for (const agentId of targets) next.add(pairKey(agentId, "active")); + return next; + }); + setErrorByAgent((prev) => { + if (!targets.some((id) => prev.has(id))) return prev; + const next = new Map(prev); + for (const id of targets) next.delete(id); + return next; + }); + void Promise.all( + targets.map(async (agentId) => { + try { + return await fetchPage(projectId, agentId, "active", 0); + } catch (e: unknown) { + if (g === gen.current) { + setErrorByAgent((prev) => new Map(prev).set(agentId, apiErrorText(e))); + } + return null; + } + }), + ).then((results) => { + for (const agentId of targets) inflight.current.delete(pairKey(agentId, "active")); + setPending((prev) => { + const next = new Set(prev); + for (const agentId of targets) next.delete(pairKey(agentId, "active")); + return next; + }); + if (g !== gen.current) return; + applyPages(results.filter((r) => r !== null)); + }); + }, + [projectId, fetchPage, applyPages], + ); + + const loadMoreFor = useCallback( + async (agentIds: string[], category: SessionCategory) => { + if (!projectId) return; + const targets = [...new Set(agentIds)].filter((agentId) => { + const position = pageStateRef.current.get(pairKey(agentId, category)); + if (position === undefined) return (countsRef.current.get(agentId)?.[category] ?? 0) > 0; + return position.hasMore; + }); + if (targets.length === 0) return; + const g = gen.current; + const results = await Promise.all( + targets.map(async (agentId) => { + const offset = pageStateRef.current.get(pairKey(agentId, category))?.fetched ?? 0; + try { + return await fetchPage(projectId, agentId, category, offset); + } catch { + // Transient failure: leave the pair's state untouched (still unloaded / + // still has-more), so the affordance stays and the user can retry — + // the sessions store's convention. + return null; + } + }), + ); + if (g !== gen.current) return; + applyPages(results.filter((r) => r !== null)); + }, + [projectId, fetchPage, applyPages], + ); + + /** Post-import refresh: refetch the Agent's active first page plus every folder category already loaded (an open folder must not blank). */ + const refreshAgent = useCallback( + async (agentId: string) => { + if (!projectId) return; + const categories: SessionCategory[] = [ + "active", + ...FOLDER_CATEGORIES.filter((cat) => pageStateRef.current.has(pairKey(agentId, cat))), + ]; + const g = gen.current; + const results = await Promise.all( + categories.map(async (category) => { + try { + return await fetchPage(projectId, agentId, category, 0); + } catch { + return null; + } + }), + ); + if (g !== gen.current) return; + applyPages( + results.filter((r) => r !== null), + agentId, + ); + }, + [projectId, fetchPage, applyPages], + ); + + const isLoadedFor = useCallback( + (agentId: string, category: SessionCategory) => pageState.has(pairKey(agentId, category)), + [pageState], + ); + + const isPendingFor = useCallback( + (agentId: string, category: SessionCategory) => pending.has(pairKey(agentId, category)), + [pending], + ); + + const hasMoreFor = useCallback( + (agentId: string, category: SessionCategory) => { + const position = pageState.get(pairKey(agentId, category)); + if (position !== undefined) return position.hasMore; + // Unloaded pair: anything the counts report is by definition still unfetched. + return (countsByAgent.get(agentId)?.[category] ?? 0) > 0; + }, + [pageState, countsByAgent], + ); + + return useMemo(() => { + const rowsByAgent = new Map(); + for (const row of rows.values()) { + const list = rowsByAgent.get(row.agentId); + if (list) list.push(row); + else rowsByAgent.set(row.agentId, [row]); + } + // sessionId embeds the creation timestamp: sorting by it descending is the same + // newest-first order the server pages with. + for (const list of rowsByAgent.values()) { + list.sort((a, b) => b.sessionId.localeCompare(a.sessionId)); + } + const allRows = [...rowsByAgent.values()].flat(); + return { + rowsByAgent, + allRows, + countsByAgent, + workspaceCountsByAgent, + errorByAgent, + isLoadedFor, + isPendingFor, + hasMoreFor, + ensureFirstFor, + loadMoreFor, + refreshAgent, + }; + }, [ + rows, + countsByAgent, + workspaceCountsByAgent, + errorByAgent, + isLoadedFor, + isPendingFor, + hasMoreFor, + ensureFirstFor, + loadMoreFor, + refreshAgent, + ]); +} diff --git a/packages/web/src/lib/session-grouping.ts b/packages/web/src/lib/session-grouping.ts index e376f96..8f9d8e4 100644 --- a/packages/web/src/lib/session-grouping.ts +++ b/packages/web/src/lib/session-grouping.ts @@ -172,7 +172,7 @@ export function latestConversation(sessions: readonly SessionInfo[]): SessionInf return best; } -export interface WorkspaceGroup { +export interface WorkspaceGroup { /** Stable group key: the Workspace path, or TEMP_WORKSPACE_GROUP_KEY for the merged temp group. */ key: string; /** Display label: the path basename; empty for the temp group (the sidebar renders the localized name). */ @@ -182,7 +182,7 @@ export interface WorkspaceGroup { /** True for the merged auto-temp group. */ temp: boolean; /** Member Sessions, newest first (createdAt desc). */ - sessions: SessionInfo[]; + sessions: T[]; } /** @@ -192,9 +192,14 @@ export interface WorkspaceGroup { * concatenates per-Agent server responses, so its order isn't globally chronological. * createdAt is a uniform ISO-8601 UTC string (server: `new Date().toISOString()`), * so lexicographic comparison equals chronological comparison. + * Generic over the row type (defaulting to SessionInfo, the sidebar's rows): the Trace + * page groups its own Session rows with the same logic — only `workspace` and the + * `createdAt` sort key are touched. */ -export function groupSessionsByWorkspace(sessions: SessionInfo[]): WorkspaceGroup[] { - const byKey = new Map(); +export function groupSessionsByWorkspace( + sessions: T[], +): WorkspaceGroup[] { + const byKey = new Map>(); for (const s of sessions) { const key = workspaceGroupKey(s.workspace); let group = byKey.get(key); diff --git a/packages/web/src/lib/strings-en.ts b/packages/web/src/lib/strings-en.ts index 8662090..1975b55 100644 --- a/packages/web/src/lib/strings-en.ts +++ b/packages/web/src/lib/strings-en.ts @@ -849,6 +849,8 @@ Scenarios: "This model cannot view images directly: on send, images are saved to the session scratchpad and passed as file paths (viewed via describe_image)", infoPanel: "Session info", sessionStats: "Stats", + /** Info-dropdown jump to the Trace page, deep-linked to the current Session. */ + viewTrace: "View trace", statTokens: "Total Tokens", statElapsed: "Elapsed", statInput: "Input tokens", @@ -1084,6 +1086,9 @@ Scenarios: toolDefs: (n: number) => `Tool definitions (${n})`, exportFile: "Export", importTrace: "Import Trace", + /** Import dialog: which Agent receives the file (the endpoint is per-Agent). */ + importAgent: "Import into Agent", + importPickFile: "Choose file", 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.", diff --git a/packages/web/src/lib/strings.ts b/packages/web/src/lib/strings.ts index b23580d..806a0a4 100644 --- a/packages/web/src/lib/strings.ts +++ b/packages/web/src/lib/strings.ts @@ -823,6 +823,8 @@ Benchmark: "当前模型不支持直接查看图片:发送时图片将保存到会话临时目录,以文件路径转交(模型经 describe_image 查看)", infoPanel: "Session 信息", sessionStats: "统计", + /** Info-dropdown jump to the Trace page, deep-linked to the current Session. */ + viewTrace: "查看轨迹", statTokens: "Token 累计", statElapsed: "用时", statInput: "输入 tokens", @@ -1054,6 +1056,9 @@ Benchmark: toolDefs: (n: number) => `工具定义(${n})`, exportFile: "导出", importTrace: "导入 Trace", + /** Import dialog: which Agent receives the file (the endpoint is per-Agent). */ + importAgent: "导入到 Agent", + importPickFile: "选择文件", importing: "导入中…", /** Client-side pre-check before reading the picked file (same cap as the server's import route). */ fileTooLarge: "文件超过 14MB 上限。", diff --git a/packages/web/test/trace-sessions.test.ts b/packages/web/test/trace-sessions.test.ts new file mode 100644 index 0000000..6b3fbde --- /dev/null +++ b/packages/web/test/trace-sessions.test.ts @@ -0,0 +1,107 @@ +/** + * Trace page Session-list logic (pure): mapping both server response shapes to the + * page's Session groups, and partitioning rows by their server-classified category. + */ +import { describe, expect, it } from "vitest"; +import type { AgentTracesResponse } from "@prismshadow/penguin-server/api"; +import { SIDEBAR_GROUP_PAGE_SIZE, SIDEBAR_PAGE_SIZE } from "../src/lib/session-grouping"; +import { + TRACES_GROUP_PAGE_SIZE, + TRACES_PAGE_SIZE, + partitionTraceRows, + toSessionGroups, +} from "../src/features/traces/trace-sessions"; +import type { TraceSessionRow } from "../src/features/traces/trace-sessions"; + +describe("trace page thresholds", () => { + it("stay larger than the sidebar's (full-height page column vs narrow sidebar), and bounded", () => { + expect(TRACES_PAGE_SIZE).toBeGreaterThan(SIDEBAR_PAGE_SIZE); + expect(TRACES_GROUP_PAGE_SIZE).toBeGreaterThan(SIDEBAR_GROUP_PAGE_SIZE); + // The fetch limit is TRACES_PAGE_SIZE + 1; the server caps limit at 1000. + expect(TRACES_PAGE_SIZE + 1).toBeLessThanOrEqual(1000); + }); +}); + +const S1 = "session-2026-07-05-10-00-00-aabbccdd"; +const S2 = "session-2026-07-06-09-00-00-11112222"; + +describe("toSessionGroups", () => { + it("paged response: server order kept, titles/classification carried, files re-sorted newest first", () => { + const res: AgentTracesResponse = { + dates: [], + sessions: [ + { + sessionId: S2, + title: "标题", + category: "subagent", + workspace: "/ws/one", + files: [ + { index: 1, date: "2026-07-06", sizeBytes: 10 }, + { index: 2, date: "2026-07-07", sizeBytes: 20 }, + ], + }, + { + sessionId: S1, + category: "active", + workspace: "", + files: [{ index: 1, date: "2026-07-05", sizeBytes: 5 }], + }, + ], + totalSessions: 12, + }; + const groups = toSessionGroups(res); + expect(groups.map((g) => g.sessionId)).toEqual([S2, S1]); + expect(groups[0]!.title).toBe("标题"); + expect(groups[0]!.category).toBe("subagent"); + expect(groups[0]!.workspace).toBe("/ws/one"); + expect(groups[0]!.files.map((f) => f.index)).toEqual([2, 1]); // newest first for display + expect(groups[1]!.title).toBeUndefined(); + }); + + it("legacy response: flattens date groups (merging a Session's files across dates), Sessions sorted by id descending, classification defaulted", () => { + const res: AgentTracesResponse = { + dates: [ + { + date: "2026-07-06", + sessions: [ + { sessionId: S2, files: [{ index: 1, sizeBytes: 1 }] }, + { sessionId: S1, files: [{ index: 2, sizeBytes: 2 }] }, + ], + }, + { date: "2026-07-05", sessions: [{ sessionId: S1, files: [{ index: 1, sizeBytes: 3 }] }] }, + ], + }; + const groups = toSessionGroups(res); + expect(groups.map((g) => g.sessionId)).toEqual([S2, S1]); + // S1's files merged across both dates, each carrying its own date, newest first. + expect(groups[1]!.files).toEqual([ + { index: 2, date: "2026-07-06", sizeBytes: 2 }, + { index: 1, date: "2026-07-05", sizeBytes: 3 }, + ]); + // No classification in the legacy shape: rows default to the active bucket / unknown workspace. + expect(groups[0]!.category).toBe("active"); + expect(groups[0]!.workspace).toBe(""); + }); +}); + +describe("partitionTraceRows", () => { + it("splits rows by their server-classified category, preserving input order", () => { + const row = (sessionId: string, category: TraceSessionRow["category"]): TraceSessionRow => ({ + sessionId, + category, + workspace: "", + files: [], + agentId: "a1", + }); + const parts = partitionTraceRows([ + row("s-4", "archived"), + row("s-3", "active"), + row("s-2", "subagent"), + row("s-1", "active"), + ]); + expect(parts.active.map((r) => r.sessionId)).toEqual(["s-3", "s-1"]); + expect(parts.subagent.map((r) => r.sessionId)).toEqual(["s-2"]); + expect(parts.schedule).toEqual([]); + expect(parts.archived.map((r) => r.sessionId)).toEqual(["s-4"]); + }); +});