feat: session source in session_meta and an Automated sidebar folder (#40)

Co-authored-by: Alice <alice@prismshadow.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-07-23 00:58:43 +08:00
committed by GitHub
parent e0bf1706b9
commit c380113e26
30 changed files with 1023 additions and 74 deletions
+80 -16
View File
@@ -20,11 +20,14 @@ import {
readTraceTolerant,
tracesDir,
} from "@prismshadow/penguin-core";
import type { ApprovalMode, SessionInfo } from "../api/types.js";
import type { SessionMetaMessage } from "@prismshadow/penguin-core";
import type { ApprovalMode, SessionInfo, SessionSource } from "../api/types.js";
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
import { badRequest } from "../http/validate.js";
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 type { ProjectConfigService } from "./project-config-service.js";
const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
@@ -44,13 +47,20 @@ export interface SessionServiceDeps {
sessions: SessionsRepo;
manager: SessionManager;
projectConfig: ProjectConfigService;
/** In-process origin registry derived from session_meta (the DB stores no source column). */
sources: SessionSources;
}
export class SessionService {
constructor(private readonly deps: SessionServiceDeps) {}
/** DB row -> SessionInfo (run status and pending approval count come from session-manager). */
toInfo(row: SessionRow, hasTrace: boolean): SessionInfo {
/**
* DB row -> SessionInfo (run status and pending approval count come from session-manager).
* Async because `source` is derived from session_meta: a registry miss (Session predating
* this process) falls back to reading the Trace head once (see sourceOf).
*/
async toInfo(row: SessionRow, hasTrace: boolean): Promise<SessionInfo> {
const source = await this.sourceOf(row, hasTrace);
return {
sessionId: row.sessionId,
projectId: row.projectId,
@@ -60,7 +70,7 @@ export class SessionService {
workspace: row.workspace,
approvalMode: row.approvalMode,
...(row.title !== null ? { title: row.title } : {}),
...(row.source != null ? { source: row.source } : {}),
...(source !== undefined ? { source } : {}),
createdAt: row.createdAt,
status: this.deps.manager.statusOf(row.sessionId),
pendingApprovalCount: this.deps.manager.pendingApprovalCount(row.sessionId),
@@ -69,14 +79,43 @@ 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.
*/
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;
const meta = await this.readTraceMeta(row.projectId, row.agentId, row.sessionId);
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;
}
/** Whether this Session already has a Trace record (a Task has been run). */
async hasTrace(row: SessionRow): Promise<boolean> {
const ids = await this.discoverTraceSessionIds(row.projectId, row.agentId);
return ids.has(row.sessionId);
}
/** List: DB ∪ Trace directory discovery, sorted by createdAt descending. */
async listSessions(projectId: string, agentId: string): Promise<SessionInfo[]> {
/**
* List: DB ∪ Trace directory discovery, sorted by createdAt descending. Optional
* `paging` returns just that slice (the sidebar pages with limit+1 to detect "has
* more"); slicing happens before toInfo, so per-request source derivation (lazy
* Trace-head reads) stays bounded by the page size. Discovery/adoption still scans
* the whole directory — the union and global ordering need every id.
*/
async listSessions(
projectId: string,
agentId: string,
paging?: { offset: number; limit: number },
): Promise<SessionInfo[]> {
const traceIds = await this.discoverTraceSessionIds(projectId, agentId);
const rows = new Map(
this.deps.sessions.listByAgent(projectId, agentId).map((r) => [r.sessionId, r]),
@@ -89,11 +128,11 @@ export class SessionService {
if (discovered) rows.set(sessionId, discovered);
}
return [...rows.values()]
.sort(
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
)
.map((row) => this.toInfo(row, traceIds.has(row.sessionId)));
const sorted = [...rows.values()].sort(
(a, b) => b.createdAt.localeCompare(a.createdAt) || b.sessionId.localeCompare(a.sessionId),
);
const page = paging ? sorted.slice(paging.offset, paging.offset + paging.limit) : sorted;
return Promise.all(page.map((row) => this.toInfo(row, traceIds.has(row.sessionId))));
}
/**
@@ -196,6 +235,8 @@ export class SessionService {
modelId,
provider,
...(args.workspace !== undefined ? { workspaceDir: args.workspace } : {}),
// The origin is also recorded in core session_meta (Trace), not just the index row.
...(args.source !== undefined ? { source: args.source } : {}),
});
} catch (err) {
// A missing credential is its own category (the frontend shows localized text
@@ -208,6 +249,14 @@ export class SessionService {
err instanceof Error ? err.message : String(err),
);
}
// The origin is derived from the just-created core Session's session_meta (the single
// source of truth) rather than echoing args.source back: what the registry serves is
// exactly what the Trace will record.
const metaMsg = session.metaMessage;
this.deps.sources.set(
session.sessionId,
isSessionMeta(metaMsg) ? (asSessionSource(metaMsg.payload.source) ?? null) : null,
);
const row: SessionRow = {
sessionId: session.sessionId,
projectId: args.projectId,
@@ -218,7 +267,6 @@ export class SessionService {
approvalMode: args.approvalMode ?? "allow-all",
title: null,
createdAt: new Date().toISOString(),
...(args.source !== undefined ? { source: args.source } : {}),
};
this.deps.sessions.insert(row);
this.deps.manager.adopt(row, session);
@@ -238,12 +286,16 @@ export class SessionService {
return ids;
}
/** 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(
/**
* session_meta from the earliest Trace shard of a Session (the shard whose head carries
* the original meta); null when there is no readable Trace or it has no meta. Shared by
* adoption backfill and lazy `source` resolution.
*/
private async readTraceMeta(
projectId: string,
agentId: string,
sessionId: string,
): Promise<SessionRow | null> {
): Promise<SessionMetaMessage | null> {
const dir = tracesDir(this.deps.root, projectId, agentId);
let earliest: { path: string; index: number } | null = null;
for (const dateDir of await listDirsSafe(dir)) {
@@ -263,13 +315,25 @@ export class SessionService {
} catch {
return null; // Corrupt file: skip (does not block the list)
}
const meta = messages.find(isSessionMeta);
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(
projectId: string,
agentId: string,
sessionId: string,
): Promise<SessionRow | null> {
const meta = await this.readTraceMeta(projectId, agentId, sessionId);
if (!meta) return null;
// 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);
const row: SessionRow = {
sessionId,
projectId,