diff --git a/.changeset/restore-archived-sessions.md b/.changeset/restore-archived-sessions.md new file mode 100644 index 000000000..2fd35cbf9 --- /dev/null +++ b/.changeset/restore-archived-sessions.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add server APIs to restore archived sessions and list only archived sessions. diff --git a/packages/server/src/lib/sessionArchive.ts b/packages/server/src/lib/sessionArchive.ts new file mode 100644 index 000000000..c78466237 --- /dev/null +++ b/packages/server/src/lib/sessionArchive.ts @@ -0,0 +1,109 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { basename, isAbsolute, join, relative, resolve } from 'node:path'; + +import { SessionNotFoundError } from '@moonshot-ai/agent-core'; + +/** + * Temporary server-side session restore. + * + * TODO: remove once `@moonshot-ai/agent-core` exposes `ISessionService.restore` + * natively. At that point the `:restore` route should delegate to the service + * instead of rewriting `state.json` here. + * + * Archive is a boolean flag (`archived`) persisted in each session's + * `/state.json`. agent-core's `SessionStore` can set it to `true` + * (`archive`) but has no inverse; while agent-core is being refactored we flip + * it back from the server by: + * 1. reading `/session_index.jsonl` to resolve `sessionId -> sessionDir`; + * 2. validating the resolved dir is inside `/sessions` (defense + * against a tampered index); + * 3. read-modify-write `state.json` with `archived: false`. + * + * This mirrors `SessionStore.archive` and publishes no event (same as archive). + * The query read-model rebuilds from the store on every call, so a restored + * session shows up in subsequent lists with no extra invalidation. + */ + +interface SessionIndexEntry { + readonly sessionId: string; + readonly sessionDir: string; + readonly workDir: string; +} + +export async function restoreArchivedSession(homeDir: string, sessionId: string): Promise { + const sessionDir = await findSessionDir(homeDir, sessionId); + if (sessionDir === undefined) { + throw new SessionNotFoundError(sessionId); + } + + const statePath = join(sessionDir, 'state.json'); + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(statePath, 'utf-8')) as unknown; + } catch { + throw new SessionNotFoundError(sessionId); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new SessionNotFoundError(sessionId); + } + + const next: Record = { + ...(parsed as Record), + archived: false, + updatedAt: new Date().toISOString(), + }; + await writeFile(statePath, `${JSON.stringify(next, null, 2)}\n`, 'utf-8'); +} + +async function findSessionDir(homeDir: string, sessionId: string): Promise { + const indexPath = join(homeDir, 'session_index.jsonl'); + let raw: string; + try { + raw = await readFile(indexPath, 'utf-8'); + } catch { + return undefined; + } + + const sessionsDir = join(homeDir, 'sessions'); + let found: string | undefined; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const entry = parseIndexLine(trimmed); + if (entry === undefined || entry.sessionId !== sessionId) continue; + const sessionDir = resolve(entry.sessionDir); + if (!isAbsolute(entry.sessionDir)) continue; + if (!isPathInside(sessionsDir, sessionDir)) continue; + if (basename(sessionDir) !== entry.sessionId) continue; + // Last valid line wins, matching `readSessionIndex`'s Map semantics. + found = sessionDir; + } + return found; +} + +function parseIndexLine(line: string): SessionIndexEntry | undefined { + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== 'object' || parsed === null) return undefined; + const entry = parsed as Partial; + if ( + typeof entry.sessionId !== 'string' || + typeof entry.sessionDir !== 'string' || + typeof entry.workDir !== 'string' + ) { + return undefined; + } + return { + sessionId: entry.sessionId, + sessionDir: entry.sessionDir, + workDir: entry.workDir, + }; + } catch { + return undefined; + } +} + +function isPathInside(parent: string, child: string): boolean { + const rel = relative(resolve(parent), resolve(child)); + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 3f3cf0c02..2e96cdd41 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -22,11 +22,12 @@ import { undoSessionResponseSchema, workspaceIdSchema, } from '@moonshot-ai/protocol'; -import { IPromptService, ISessionService, SessionNotFoundError, SessionUndoUnavailableError, ErrorCodes, KimiError, IWorkspaceRegistry, WorkspaceNotFoundError, type IInstantiationService, type SessionClientTelemetry } from '@moonshot-ai/agent-core'; +import { IPromptService, ISessionService, SessionNotFoundError, SessionUndoUnavailableError, ErrorCodes, KimiError, IEnvironmentService, IWorkspaceRegistry, WorkspaceNotFoundError, type IInstantiationService, type SessionClientTelemetry } from '@moonshot-ai/agent-core'; import { z } from 'zod'; import { errEnvelope, okEnvelope } from '../envelope'; +import { restoreArchivedSession } from '../lib/sessionArchive'; import { defineRoute } from '../middleware/defineRoute'; import { parseActionSuffix } from './action-suffix'; @@ -82,6 +83,7 @@ const sessionsListQueryCoercion = z page_size: z.coerce.number().int().min(1).max(100).optional(), status: sessionStatusSchema.optional(), include_archive: booleanQueryParam, + archived_only: booleanQueryParam, workspace_id: workspaceIdSchema.optional(), }) @@ -94,6 +96,14 @@ const sessionsListQueryCoercion = z params: { code: ErrorCode.VALIDATION_FAILED }, }); } + if (value.archived_only === true && value.include_archive === true) { + ctx.addIssue({ + code: 'custom', + message: 'archived_only and include_archive are mutually exclusive', + path: ['archived_only'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } }); const sessionChildrenListQueryCoercion = z @@ -155,6 +165,56 @@ function headerString(headers: Record, key: string): string | u return trimmed.length === 0 ? undefined : trimmed; } +const DEFAULT_SESSION_LIST_PAGE_SIZE = 20; +const MAX_SESSION_LIST_PAGE_SIZE = 100; + +type SessionListRequest = Parameters[0]; +type SessionListPage = Awaited>; +type SessionListItem = SessionListPage['items'][number]; +type SessionListBaseQuery = Omit; +type SessionListCursor = Pick; + +function normalizeSessionListPageSize(cursor: SessionListCursor): number { + const requested = cursor.page_size ?? DEFAULT_SESSION_LIST_PAGE_SIZE; + return Math.min(Math.max(requested, 1), MAX_SESSION_LIST_PAGE_SIZE); +} + +async function listSessionsWithRouteFilter( + fetchPage: (query: SessionListRequest) => Promise, + baseQuery: SessionListBaseQuery, + cursor: SessionListCursor, + predicate: (session: SessionListItem) => boolean, +): Promise { + const targetSize = normalizeSessionListPageSize(cursor); + const matches: SessionListItem[] = []; + let beforeId = cursor.before_id; + let afterId = cursor.after_id; + let coreHasMore = true; + + while (matches.length <= targetSize && coreHasMore) { + const page = await fetchPage({ + ...baseQuery, + before_id: beforeId, + after_id: afterId, + page_size: MAX_SESSION_LIST_PAGE_SIZE, + }); + if (page.items.length === 0) break; + + matches.push(...page.items.filter(predicate)); + coreHasMore = page.has_more; + + const nextBeforeId = page.items[page.items.length - 1]?.id; + if (!coreHasMore || nextBeforeId === undefined || nextBeforeId === beforeId) break; + beforeId = nextBeforeId; + afterId = undefined; + } + + return { + items: matches.slice(0, targetSize), + has_more: matches.length > targetSize, + }; +} + export function registerSessionsRoutes( app: SessionRouteHost, ix: IInstantiationService, @@ -265,14 +325,11 @@ export function registerSessionsRoutes( async (req, reply) => { try { const raw = req.query; - const baseQuery = { - before_id: raw.before_id, - after_id: raw.after_id, - page_size: raw.page_size, - status: raw.status, - includeArchive: raw.include_archive, + const archivedOnly = raw.archived_only === true; + const status = raw.status; + let baseQuery: SessionListBaseQuery = { + includeArchive: archivedOnly ? true : raw.include_archive, }; - let query; if (raw.workspace_id !== undefined) { const registry = ix.invokeFunction((a) => a.get(IWorkspaceRegistry)); let root: string; @@ -287,11 +344,30 @@ export function registerSessionsRoutes( } throw err; } - query = { ...baseQuery, workDir: root }; - } else { - query = baseQuery; + baseQuery = { ...baseQuery, workDir: root }; + } + + if (archivedOnly) { + const page = await listSessionsWithRouteFilter( + (query) => ix.invokeFunction((a) => a.get(ISessionService).list(query)), + baseQuery, + raw, + (session) => + session.archived === true && (status === undefined || session.status === status), + ); + reply.send(okEnvelope(page, req.id)); + return; } - const page = await ix.invokeFunction((a) => a.get(ISessionService).list(query)); + + const page = await ix.invokeFunction((a) => + a.get(ISessionService).list({ + ...baseQuery, + before_id: raw.before_id, + after_id: raw.after_id, + page_size: raw.page_size, + status, + }), + ); reply.send(okEnvelope(page, req.id)); } catch (err) { sendMappedError(reply, req.id, err); @@ -410,7 +486,7 @@ export function registerSessionsRoutes( const { tail } = req.params; const parsed = parseActionSuffix({ tail, - allowedActions: ['fork', 'compact', 'undo', 'abort', 'btw', 'archive'] as const, + allowedActions: ['fork', 'compact', 'undo', 'abort', 'btw', 'archive', 'restore'] as const, resourceLabel: 'session', }); if (parsed.kind !== 'action') { @@ -468,6 +544,16 @@ export function registerSessionsRoutes( return; } + if (parsed.action === 'restore') { + const homeDir = ix.invokeFunction((a) => a.get(IEnvironmentService)).homeDir; + await restoreArchivedSession(homeDir, parsed.id); + const session = await ix.invokeFunction((a) => + a.get(ISessionService).get(parsed.id), + ); + reply.send(okEnvelope(session, req.id)); + return; + } + const body = undoSessionRequestSchema.parse(req.body); const result = await ix.invokeFunction((a) => a.get(ISessionService).undo(parsed.id, body), diff --git a/packages/server/test/question.e2e.test.ts b/packages/server/test/question.e2e.test.ts index 2c56760ed..3ce55f460 100644 --- a/packages/server/test/question.e2e.test.ts +++ b/packages/server/test/question.e2e.test.ts @@ -49,8 +49,8 @@ afterEach(async () => { // ignore } server = undefined; - rmSync(tmpDir, { recursive: true, force: true }); - rmSync(bridgeHome, { recursive: true, force: true }); + rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + rmSync(bridgeHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); }); async function bootDaemon(): Promise { diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 52231b3d6..ad3c407da 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -811,3 +811,167 @@ describe('POST /api/v1/sessions/{session_id}:archive — archive', () => { expect(env.code).toBe(40401); }); }); + +describe('GET /api/v1/sessions?archived_only — archived-only list', () => { + it('returns only archived sessions and hides them from the default list', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-archived-only'); + const created = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:archive`, + payload: {}, + }); + + const defaultList = envelopeOf<{ items: Array<{ id: string }> }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions' })).json(), + ); + expect(defaultList.data!.items.find((s) => s.id === created.id)).toBeUndefined(); + + const archivedOnly = envelopeOf<{ items: Array<{ id: string; archived?: boolean }>; has_more: boolean }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions?archived_only=true' })).json(), + ); + expect(archivedOnly.code).toBe(0); + const listed = archivedOnly.data!.items.find((s) => s.id === created.id); + expect(listed).toBeDefined(); + expect(listed!.archived).toBe(true); + // No live session should leak into the archived-only view. + expect(archivedOnly.data!.items.every((s) => s.archived === true)).toBe(true); + }); + + it('paginates archived_only without returning empty filtered pages', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-archived-only-pagination'); + + const archivedOlder = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${archivedOlder.id}:archive`, + payload: {}, + }); + + const archivedNewer = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${archivedNewer.id}:archive`, + payload: {}, + }); + + await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + }); + await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + }); + + const first = envelopeOf<{ items: Array<{ id: string; archived?: boolean }>; has_more: boolean }>( + (await appOf(r).inject({ + method: 'GET', + url: '/api/v1/sessions?archived_only=true&page_size=1', + })).json(), + ); + expect(first.code).toBe(0); + expect(first.data!.items).toHaveLength(1); + expect(first.data!.items[0]).toMatchObject({ id: archivedNewer.id, archived: true }); + expect(first.data!.has_more).toBe(true); + + const second = envelopeOf<{ items: Array<{ id: string; archived?: boolean }>; has_more: boolean }>( + (await appOf(r).inject({ + method: 'GET', + url: `/api/v1/sessions?archived_only=true&page_size=1&before_id=${archivedNewer.id}`, + })).json(), + ); + expect(second.code).toBe(0); + expect(second.data!.items).toHaveLength(1); + expect(second.data!.items[0]).toMatchObject({ id: archivedOlder.id, archived: true }); + expect(second.data!.has_more).toBe(false); + }); + + it('rejects archived_only together with include_archive', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'GET', + url: '/api/v1/sessions?archived_only=true&include_archive=true', + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40001); + }); +}); + +describe('POST /api/v1/sessions/{session_id}:restore — restore', () => { + it('restores an archived session so it reappears in the default list', async () => { + const r = await bootDaemon(); + const cwd = join(tmpDir, 'workspace-restore'); + const created = envelopeOf<{ id: string }>( + (await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions', + payload: { metadata: { cwd } }, + })).json(), + ).data!; + + await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:archive`, + payload: {}, + }); + + const restoreRes = await appOf(r).inject({ + method: 'POST', + url: `/api/v1/sessions/${created.id}:restore`, + payload: {}, + }); + const restoreEnv = envelopeOf(restoreRes.json()); + expect(restoreEnv.code).toBe(0); + const session = sessionSchema.parse(restoreEnv.data); + expect(session.id).toBe(created.id); + expect(session.archived).toBe(false); + + const defaultList = envelopeOf<{ items: Array<{ id: string; archived?: boolean }> }>( + (await appOf(r).inject({ method: 'GET', url: '/api/v1/sessions' })).json(), + ); + const relisted = defaultList.data!.items.find((s) => s.id === created.id); + expect(relisted).toBeDefined(); + expect(relisted!.archived).toBe(false); + + const getRes = envelopeOf( + (await appOf(r).inject({ method: 'GET', url: `/api/v1/sessions/${created.id}` })).json(), + ); + expect(getRes.code).toBe(0); + expect(sessionSchema.parse(getRes.data).archived).toBe(false); + }); + + it('returns 40401 for unknown id', async () => { + const r = await bootDaemon(); + const res = await appOf(r).inject({ + method: 'POST', + url: '/api/v1/sessions/sess_missing:restore', + payload: {}, + }); + const env = envelopeOf(res.json()); + expect(env.code).toBe(40401); + }); +});