From 1d31b4283de6d8ee03aff2dac5132665c976769a Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 23 Jun 2026 21:17:05 +0800 Subject: [PATCH 1/3] feat(server): support restoring and listing archived sessions - add a `:restore` session action that clears the archived flag in state.json and returns the restored session - add an `archived_only` list query param, mutually exclusive with `include_archive`, that post-filters to archived sessions - keep the implementation in the server layer as a temporary measure until agent-core exposes restore natively --- .changeset/restore-archived-sessions.md | 5 + packages/server/src/lib/sessionArchive.ts | 109 ++++++++++++++++++++++ packages/server/src/routes/sessions.ts | 46 ++++++++- packages/server/test/sessions.e2e.test.ts | 100 ++++++++++++++++++++ 4 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 .changeset/restore-archived-sessions.md create mode 100644 packages/server/src/lib/sessionArchive.ts 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..f0d539e85 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 @@ -265,12 +275,15 @@ export function registerSessionsRoutes( async (req, reply) => { try { const raw = req.query; + const archivedOnly = raw.archived_only === true; const baseQuery = { before_id: raw.before_id, after_id: raw.after_id, page_size: raw.page_size, status: raw.status, - includeArchive: raw.include_archive, + // archived_only needs the mixed set so we can post-filter to + // archived-only below (temporary server-side filter; see comment). + includeArchive: archivedOnly ? true : raw.include_archive, }; let query; if (raw.workspace_id !== undefined) { @@ -292,6 +305,23 @@ export function registerSessionsRoutes( query = baseQuery; } const page = await ix.invokeFunction((a) => a.get(ISessionService).list(query)); + if (archivedOnly) { + // Temporary server-side archived-only filter. The post-filter happens + // after pagination, so `has_more` still reflects the mixed set: a page + // may come back short (or empty) while `has_more` is true, and clients + // must keep paging. No data is lost. Remove once agent-core supports + // archived-only natively. + reply.send( + okEnvelope( + { + items: page.items.filter((session) => session.archived === true), + has_more: page.has_more, + }, + req.id, + ), + ); + return; + } reply.send(okEnvelope(page, req.id)); } catch (err) { sendMappedError(reply, req.id, err); @@ -410,7 +440,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 +498,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/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 52231b3d6..4693086d6 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -811,3 +811,103 @@ 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('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); + }); +}); From 055bc3bb40995157d55bf683b4f71ac6c72a9853 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 24 Jun 2026 19:27:53 +0800 Subject: [PATCH 2/3] fix(server): paginate archived-only sessions before response --- packages/server/src/routes/sessions.ts | 96 +++++++++++++++++------ packages/server/test/sessions.e2e.test.ts | 64 +++++++++++++++ 2 files changed, 135 insertions(+), 25 deletions(-) diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index f0d539e85..2e96cdd41 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -165,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, @@ -276,16 +326,10 @@ export function registerSessionsRoutes( try { const raw = req.query; const archivedOnly = raw.archived_only === true; - const baseQuery = { - before_id: raw.before_id, - after_id: raw.after_id, - page_size: raw.page_size, - status: raw.status, - // archived_only needs the mixed set so we can post-filter to - // archived-only below (temporary server-side filter; see comment). + 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; @@ -300,28 +344,30 @@ export function registerSessionsRoutes( } throw err; } - query = { ...baseQuery, workDir: root }; - } else { - query = baseQuery; + baseQuery = { ...baseQuery, workDir: root }; } - const page = await ix.invokeFunction((a) => a.get(ISessionService).list(query)); + if (archivedOnly) { - // Temporary server-side archived-only filter. The post-filter happens - // after pagination, so `has_more` still reflects the mixed set: a page - // may come back short (or empty) while `has_more` is true, and clients - // must keep paging. No data is lost. Remove once agent-core supports - // archived-only natively. - reply.send( - okEnvelope( - { - items: page.items.filter((session) => session.archived === true), - has_more: page.has_more, - }, - req.id, - ), + 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({ + ...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); diff --git a/packages/server/test/sessions.e2e.test.ts b/packages/server/test/sessions.e2e.test.ts index 4693086d6..ad3c407da 100644 --- a/packages/server/test/sessions.e2e.test.ts +++ b/packages/server/test/sessions.e2e.test.ts @@ -846,6 +846,70 @@ describe('GET /api/v1/sessions?archived_only — archived-only list', () => { 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({ From 1e2492046ccf134845cad6fcf2e81788cab8e5ec Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 24 Jun 2026 21:22:35 +0800 Subject: [PATCH 3/3] test(server): retry temp dir cleanup in question e2e --- packages/server/test/question.e2e.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 {