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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 上限。",
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user