feat(traces): mirror sidebar grouping (workspace mode, category folders, shared components), paginate sessions, title fallback to first prompt (#183)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 23:07:06 +08:00
committed by GitHub
parent 89435bf62a
commit ab388b4f8c
30 changed files with 3144 additions and 718 deletions
+50 -1
View File
@@ -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<string, SessionCategoryCounts>;
}
export interface TraceImportRequest {
+21 -5
View File
@@ -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,
+187
View File
@@ -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<string, unknown>): 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<string, unknown>): 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);
}
}
+24
View File
@@ -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 <session_id>_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);
`;
@@ -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<AppEnv> {
const app = new Hono<AppEnv>();
@@ -35,7 +56,30 @@ export function agentTracesRoutes(deps: AppDeps): Hono<AppEnv> {
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) => {
@@ -76,6 +76,8 @@ export function agentsRoutes(deps: AppDeps): Hono<AppEnv> {
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).
@@ -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<OmniMessage[]> {
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));
}
@@ -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)
@@ -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 });
}
+73 -165
View File
@@ -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<string, TraceLocation>,
): Promise<SessionInfo> {
const source = await this.sourceOf(row, hasTrace, traces);
async toInfo(row: SessionRow, hasTrace: boolean): Promise<SessionInfo> {
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<string, TraceLocation>,
): Promise<SessionSource | undefined> {
private async sourceOf(row: SessionRow, hasTrace: boolean): Promise<SessionSource | undefined> {
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<boolean> {
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<string, TraceLocation>,
): Promise<SessionCategory> {
private async categoryOf(row: SessionRow, hasTrace: boolean): Promise<SessionCategory> {
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<string, TraceLocation> | undefined;
let traces: ReadonlySet<string> | 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<string | undefined> {
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<Map<string, TraceLocation>> {
const dir = tracesDir(this.deps.root, projectId, agentId);
const out = new Map<string, TraceLocation>();
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<Set<string>> {
await this.deps.traceIndex.reconcileAgent(projectId, agentId);
const out = new Set<string>();
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<SessionMetaMessage | null> {
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<SessionRow | null> {
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<string[]> {
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<string[]> {
try {
const entries = await readdir(dir, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => e.name);
} catch {
return [];
}
}
+329
View File
@@ -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<number | null> {
try {
return (await fs.stat(p)).mtimeMs;
} catch {
return null;
}
}
async function listDirs(dir: string): Promise<string[]> {
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<string[]> {
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<string, number>;
}
export class TraceIndexService {
private readonly seen = new Map<string, SeenDirs>();
private readonly inflight = new Map<string, Promise<void>>();
/**
* 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<void> {
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<void> {
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<void> {
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<string, TraceFileRow[]>();
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<string>();
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<void> {
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);
}
}
}
+251 -79
View File
@@ -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<string[]> {
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<string[]> {
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<LocatedFile[]> {
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<void> {
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<OmniMessage[]> {
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<Map<string, string>> {
const index = new Map<string, string>();
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<string | null> {
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<string, string> | null; ancestry: Set<string>; depth: number },
ctx: { projectScanned: boolean; ancestry: Set<string>; depth: number },
): Promise<OmniMessage[]> {
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<AgentTracesResponse> {
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<string, { index: number; sizeBytes: number }[]>();
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<AgentTracesResponse> {
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<string, Map<string, { index: number; sizeBytes: number }[]>>();
for (const f of files) {
const sessions =
byDate.get(f.date) ?? new Map<string, { index: number; sizeBytes: number }[]>();
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<AgentTracesResponse> {
const bySession = new Map<string, TraceFileRow[]>();
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<string, SessionCategoryCounts> = {};
const factsById = new Map<string, TraceSessionFacts>();
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 };
}
}
@@ -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);
+31
View File
@@ -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<AppEnv>, 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,
+179
View File
@@ -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<void> {
// 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> = {}): 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<typeof makeTraceHarness>;
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([]);
});
});
+194 -4
View File
@@ -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> = {}): 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<SessionRow> & { 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<typeof makeTraceHarness>;
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 () => {
@@ -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<typeof makeTraceHarness>;
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 });
});
+21 -2
View File
@@ -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<AgentTracesResponse>(
`/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 ---------------------------------------------------------------------
@@ -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";
+101 -196
View File
@@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={d} />
</svg>
);
}
/** 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<string | null>(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({
</ul>
);
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 (
<div key={category} className="mt-1">
<button
type="button"
onClick={() => toggleFolder(groupKey, category, agentIds)}
className={folderClass}
>
<Chevron open={open} size={12} />
{S.chat.folderGroups[category](total)}
</button>
{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 && (
<button
type="button"
aria-label={S.chat.loadMore}
disabled={pending}
onClick={() => trackedLoadMore(groupKey, category, agentIds)}
className={`${folderClass} disabled:opacity-60`}
>
<span className="w-3" aria-hidden />
{pending ? S.common.loading : S.chat.loadMore}
</button>
)}
</div>
<FolderSection
key={category}
label={S.chat.folderGroups[category](total)}
open={openFolders.has(folderKey(groupKey, category))}
onToggle={() => toggleFolder(groupKey, category, agentIds)}
more={more}
pending={pendingLoads.has(loadKey(groupKey, category))}
onMore={() => trackedLoadMore(groupKey, category, agentIds)}
>
{renderRows(rows, withAgentHint)}
</FolderSection>
);
};
@@ -654,16 +603,12 @@ export function Sidebar({
{/* Load/reveal more (kept adjacent to the active list it extends, above the folders) */}
{hasMore && (
<button
type="button"
aria-label={S.chat.loadMore}
disabled={activePending}
<MoreRow
label={S.chat.loadMore}
pending={activePending}
onClick={() => showMore(groupKey, activeAgents)}
className={`${folderClass} mt-0.5 disabled:opacity-60`}
>
<span className="w-3" aria-hidden />
{activePending ? S.common.loading : S.chat.loadMore}
</button>
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) => (
<button
type="button"
<MoreRow
label={S.chat.moreGroups(total - groupCap)}
onClick={() => setGroupCap((c) => c + SIDEBAR_GROUP_PAGE_SIZE)}
className={`${folderClass} mt-1`}
>
<span className="w-3" aria-hidden />
{S.chat.moreGroups(total - groupCap)}
</button>
className="mt-1"
/>
);
const navItems: Array<{ to: string; label: string; icon: string }> = [
@@ -860,30 +802,7 @@ export function Sidebar({
<span className="px-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
{S.chat.sessionList}
</span>
<div className="flex items-center gap-0.5">
{(
[
{ value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace },
{ value: "agent", icon: NAV_ICONS.agents, label: S.chat.groupByAgent },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
title={opt.label}
aria-label={opt.label}
aria-pressed={groupMode === opt.value}
onClick={() => setGroupMode(opt.value)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors duration-150 ${
groupMode === opt.value
? "bg-gray-200/70 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-400 hover:bg-gray-200/50 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800/70 dark:hover:text-gray-300"
}`}
>
<Icon d={opt.icon} size={14} />
</button>
))}
</div>
<GroupModeToggle value={groupMode} onChange={setGroupMode} />
</div>
{groupMode === "agent" ? (
@@ -896,50 +815,44 @@ export function Sidebar({
const pinned = pinnedGroups.has(agent.agentId);
return (
<div key={agent.agentId} className="pt-2.5">
{/* 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). */}
<div className="group/header flex items-center gap-0.5 px-1 pb-0.5">
<button
type="button"
onClick={() => toggleGroup(agent.agentId)}
aria-expanded={!collapsed}
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
className="flex min-w-0 flex-1 items-center gap-1 self-stretch rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
{/* Group header: collapse toggle (Agent name) + pin + new chat + Agent settings. */}
<GroupHeader
open={!collapsed}
onToggle={() => toggleGroup(agent.agentId)}
icon={
<AgentAvatar
id={agent.agentId}
name={agentDisplayName(agent)}
size={18}
className="shrink-0 rounded"
/>
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{agentDisplayName(agent)}
</span>
{/* Expand/collapse indicator sits right after the Agent name */}
<Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
<GroupPinButton pinned={pinned} onToggle={() => togglePin(agent.agentId)} />
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
<button
type="button"
title={S.chat.newSessionMenu}
aria-label={S.chat.newSessionMenu}
onClick={() => newChat(agent.agentId)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
<button
type="button"
title={S.agent.settings}
onClick={() => go(`/agents/${agent.agentId}`)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d={GEAR_ICON} size={16} />
</button>
</div>
}
label={agentDisplayName(agent)}
uppercase
actions={
<>
<GroupPinButton pinned={pinned} onToggle={() => togglePin(agent.agentId)} />
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
<button
type="button"
title={S.chat.newSessionMenu}
aria-label={S.chat.newSessionMenu}
onClick={() => newChat(agent.agentId)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
<button
type="button"
title={S.agent.settings}
onClick={() => go(`/agents/${agent.agentId}`)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d={GEAR_ICON} size={16} />
</button>
</>
}
/>
{collapsed
? null
@@ -977,46 +890,38 @@ export function Sidebar({
];
return (
<div key={group.key} className="pt-2.5">
{/* 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. */}
<div className="group/header flex items-center gap-0.5 px-1 pb-0.5">
<button
type="button"
onClick={() => toggleGroup(group.key)}
aria-expanded={!collapsed}
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
{...(group.fullPath !== null ? { title: group.fullPath } : {})}
className="flex min-w-0 flex-1 items-center gap-1 self-stretch rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
{/* Folder opens and closes with the group */}
{/* Group header: collapse toggle (folder icon + directory basename + count, full
path in the tooltip; the count = the group's active conversations only, exact
server share, loaded rows win a disagreement — the folders never feed it) +
pin + new chat in this Workspace. */}
<GroupHeader
open={!collapsed}
onToggle={() => toggleGroup(group.key)}
icon={
/* Folder opens and closes with the group */
<span className="shrink-0 text-gray-400 dark:text-gray-500">
<Icon d={collapsed ? FOLDER_ICON : FOLDER_OPEN_ICON} size={15} />
</span>
{/* No uppercase transform: a directory basename's casing is meaningful */}
<span className="min-w-0 truncate text-xs font-semibold text-gray-500 dark:text-gray-400">
{group.temp ? S.chat.tempWorkspaces : group.label}
</span>
{/* Header count = the group's active conversations only (exact server share;
loaded rows win a disagreement) — the folders never feed it. */}
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
{Math.max(counts?.totals.active ?? 0, parts.active.length)}
</span>
<Chevron open={!collapsed} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
<GroupPinButton pinned={pinned} onToggle={() => 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 */}
<button
type="button"
title={S.chat.newSessionInWorkspace}
aria-label={S.chat.newSessionInWorkspace}
onClick={() => newChat(workspaceNewChatAgentId, group.fullPath ?? "")}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
</div>
}
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={
<>
<GroupPinButton pinned={pinned} onToggle={() => 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 */}
<button
type="button"
title={S.chat.newSessionInWorkspace}
aria-label={S.chat.newSessionInWorkspace}
onClick={() => newChat(workspaceNewChatAgentId, group.fullPath ?? "")}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<Icon d="M12 5v14M5 12h14" size={18} />
</button>
</>
}
/>
{/* 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
@@ -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 (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={d} />
</svg>
);
}
/** 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 (
<div className="flex items-center gap-0.5">
{(
[
{ value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace },
{ value: "agent", icon: AGENT_GROUP_ICON, label: S.chat.groupByAgent },
] as const
).map((opt) => (
<button
key={opt.value}
type="button"
title={opt.label}
aria-label={opt.label}
aria-pressed={value === opt.value}
onClick={() => onChange(opt.value)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors duration-150 ${
value === opt.value
? "bg-gray-200/70 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
: "text-gray-400 hover:bg-gray-200/50 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800/70 dark:hover:text-gray-300"
}`}
>
<Icon d={opt.icon} size={14} />
</button>
))}
</div>
);
}
/** 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 (
<button
type="button"
aria-label={S.chat.loadMore}
disabled={pending}
onClick={onClick}
className={`${FOLDER_ROW_CLASS}${className ? ` ${className}` : ""} disabled:opacity-60`}
>
<span className="w-3" aria-hidden />
{pending ? S.common.loading : label}
</button>
);
}
/**
* 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 (
<div className="mt-1">
<button type="button" onClick={onToggle} className={FOLDER_ROW_CLASS}>
<Chevron open={open} size={12} />
{label}
</button>
{open && children}
{open && more && (
<MoreRow label={S.chat.loadMore} pending={pending} onClick={() => onMore?.()} />
)}
</div>
);
}
/**
* 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 (
<div className="group/header flex items-center gap-0.5 px-1 pb-0.5">
<button
type="button"
onClick={onToggle}
aria-expanded={open}
aria-label={open ? S.nav.collapseGroup : S.nav.expandGroup}
{...(title !== undefined ? { title } : {})}
className="flex min-w-0 flex-1 items-center gap-1 self-stretch rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
>
{icon}
<span
className={`min-w-0 truncate text-xs font-semibold ${
uppercase ? "uppercase tracking-wide " : ""
}text-gray-500 dark:text-gray-400`}
>
{label}
</span>
{count !== undefined && (
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">{count}</span>
)}
{/* Expand/collapse indicator sits right after the label */}
<Chevron open={open} size={12} className="text-gray-400" />
<span className="min-w-0 flex-1" />
</button>
{actions}
</div>
);
}
+19
View File
@@ -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({
</button>
);
}
/**
* 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;
@@ -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() {
</p>
</div>
</div>
{/* 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. */}
<div className="border-t border-gray-100 py-1 dark:border-gray-800">
<button
type="button"
onClick={() => {
setInfoOpen(false);
navigate(
`/traces?agentId=${encodeURIComponent(selected.agentId)}&sessionId=${encodeURIComponent(selected.sessionId)}`,
);
}}
className="flex w-full items-center gap-2 px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="shrink-0 text-gray-400 dark:text-gray-500"
>
<path d={NAV_ICONS.traces} />
</svg>
{S.chat.viewTrace}
</button>
</div>
</Dropdown>
</div>
)}
@@ -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<string, TraceFileRef[]>();
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<T extends { category: SessionCategory }>(
rows: readonly T[],
): Record<SessionCategory, T[]> {
const parts: Record<SessionCategory, T[]> = {
active: [],
subagent: [],
schedule: [],
archived: [],
};
for (const r of rows) parts[r.category].push(r);
return parts;
}
File diff suppressed because it is too large Load Diff
@@ -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<Record<string, SessionCategoryCounts>>;
}
export interface TraceTree {
/** agentId → its pooled rows (every fetched category), newest first by sessionId. */
rowsByAgent: ReadonlyMap<string, TraceSessionRow[]>;
/** All pooled rows (workspace-mode grouping input). */
allRows: TraceSessionRow[];
/** agentId → per-category totals over ALL of the Agent's Trace session groups. */
countsByAgent: ReadonlyMap<string, SessionCategoryCounts>;
/** agentId → the same totals broken down by Workspace path (workspace-mode group headers). */
workspaceCountsByAgent: ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>;
/** agentId → active-first-page load failure (inline tree error, mirroring the load-state-with-content rule). */
errorByAgent: ReadonlyMap<string, string>;
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<void>;
/** Drops and refetches one Agent's loaded pairs (post-import refresh), keeping open folders populated. */
refreshAgent: (agentId: string) => Promise<void>;
}
/**
* @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<ReadonlyMap<string, TraceSessionRow>>(new Map());
const [pageState, setPageState] = useState<ReadonlyMap<string, PagePosition>>(new Map());
const [countsByAgent, setCountsByAgent] = useState<ReadonlyMap<string, SessionCategoryCounts>>(
new Map(),
);
const [workspaceCountsByAgent, setWorkspaceCountsByAgent] = useState<
ReadonlyMap<string, Readonly<Record<string, SessionCategoryCounts>>>
>(new Map());
const [errorByAgent, setErrorByAgent] = useState<ReadonlyMap<string, string>>(new Map());
const [pending, setPending] = useState<ReadonlySet<string>>(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<string>());
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<string, PagePosition>();
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<FetchedPage> => {
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<TraceTree>(() => {
const rowsByAgent = new Map<string, TraceSessionRow[]>();
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,
]);
}
+9 -4
View File
@@ -172,7 +172,7 @@ export function latestConversation(sessions: readonly SessionInfo[]): SessionInf
return best;
}
export interface WorkspaceGroup {
export interface WorkspaceGroup<T = SessionInfo> {
/** 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<string, WorkspaceGroup>();
export function groupSessionsByWorkspace<T extends { workspace: string; createdAt: string }>(
sessions: T[],
): WorkspaceGroup<T>[] {
const byKey = new Map<string, WorkspaceGroup<T>>();
for (const s of sessions) {
const key = workspaceGroupKey(s.workspace);
let group = byKey.get(key);
+5
View File
@@ -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.",
+5
View File
@@ -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 上限。",
+107
View File
@@ -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"]);
});
});