saved sessions: replace transient history with persistent DB storage and auto-save
- Add saved_sessions table and CRUD endpoints (save, list, resume, update, delete) - Save is instant (no LLM summarization), stores exact conversation with tool calls - Resume loads full message history into chat UI, sends transcript to agent on first message - Auto-save updates DB after every agent response once a session is saved - Delete old filesystem-based session/group management (sessions router, useChatSessions, useChatGroups) - Clean up ChatHeader, SessionList, ChatDetailPanel for saved sessions flow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import {
|
||||
listSavedSessions,
|
||||
getSavedSession,
|
||||
createSavedSession,
|
||||
updateSavedSessionMessages,
|
||||
deleteSavedSession,
|
||||
} from 'officerdb';
|
||||
import * as storage from '../pi/storage';
|
||||
import { getHomeDirForRole } from '../../data-path';
|
||||
import type { Message, MessageCost } from '../pi/types';
|
||||
|
||||
export const savedSessionsRouter = createRouter();
|
||||
|
||||
// POST /saved-sessions — save session instantly (no LLM summary)
|
||||
savedSessionsRouter.post('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const { sessionId } = ctx.get('body') as { sessionId: string };
|
||||
|
||||
if (!sessionId) {
|
||||
return ctx.json({ error: 'sessionId is required' }, 400);
|
||||
}
|
||||
|
||||
const homeDir = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
let meta;
|
||||
let messages: Message[];
|
||||
try {
|
||||
const loaded = await storage.loadSession(homeDir, sessionId);
|
||||
meta = loaded.meta;
|
||||
messages = loaded.messages;
|
||||
} catch {
|
||||
return ctx.json({ error: 'Session not found on disk' }, 404);
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await createSavedSession({
|
||||
userId: user.id,
|
||||
provider: meta.model,
|
||||
context: meta.context ?? null,
|
||||
contextId: meta.contextId ?? null,
|
||||
title: meta.title,
|
||||
summary: meta.title,
|
||||
rawMessages: messages,
|
||||
cwd: meta.cwd,
|
||||
cost: meta.cost as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
return ctx.json(saved, 201);
|
||||
} catch (err) {
|
||||
console.error('Failed to create saved session:', err);
|
||||
return ctx.json({ error: 'Failed to save session' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /saved-sessions — list (no rawMessages)
|
||||
savedSessionsRouter.get('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const rows = await listSavedSessions(user.id);
|
||||
return ctx.json(rows);
|
||||
});
|
||||
|
||||
// GET /saved-sessions/:id — full record
|
||||
savedSessionsRouter.get('/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
|
||||
if (Number.isNaN(id)) {
|
||||
return ctx.json({ error: 'Invalid id' }, 400);
|
||||
}
|
||||
|
||||
const session = await getSavedSession(id, user.id);
|
||||
if (!session) {
|
||||
return ctx.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
return ctx.json(session);
|
||||
});
|
||||
|
||||
// PUT /saved-sessions/:id — update messages from disk (auto-save after each turn)
|
||||
savedSessionsRouter.put('/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
const { sessionId } = ctx.get('body') as { sessionId: string };
|
||||
|
||||
if (Number.isNaN(id)) {
|
||||
return ctx.json({ error: 'Invalid id' }, 400);
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return ctx.json({ error: 'sessionId is required' }, 400);
|
||||
}
|
||||
|
||||
const homeDir = getHomeDirForRole(user.email, user.role);
|
||||
|
||||
let meta;
|
||||
let messages: Message[];
|
||||
try {
|
||||
const loaded = await storage.loadSession(homeDir, sessionId);
|
||||
meta = loaded.meta;
|
||||
messages = loaded.messages;
|
||||
} catch {
|
||||
return ctx.json({ error: 'Session not found on disk' }, 404);
|
||||
}
|
||||
|
||||
const updated = await updateSavedSessionMessages(
|
||||
id,
|
||||
user.id,
|
||||
messages,
|
||||
meta.cost as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
return ctx.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// DELETE /saved-sessions/:id
|
||||
savedSessionsRouter.delete('/:id', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
|
||||
if (Number.isNaN(id)) {
|
||||
return ctx.json({ error: 'Invalid id' }, 400);
|
||||
}
|
||||
|
||||
const deleted = await deleteSavedSession(id, user.id);
|
||||
if (!deleted) {
|
||||
return ctx.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /saved-sessions/:id/resume — get full session for resuming
|
||||
savedSessionsRouter.post('/:id/resume', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('id'));
|
||||
|
||||
if (Number.isNaN(id)) {
|
||||
return ctx.json({ error: 'Invalid id' }, 400);
|
||||
}
|
||||
|
||||
const session = await getSavedSession(id, user.id);
|
||||
if (!session) {
|
||||
return ctx.json({ error: 'Not found' }, 404);
|
||||
}
|
||||
|
||||
const cost = session.cost as unknown as MessageCost;
|
||||
|
||||
return ctx.json({
|
||||
context: session.context,
|
||||
cwd: session.cwd,
|
||||
model: session.provider,
|
||||
cost,
|
||||
rawMessages: session.rawMessages,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user