fix(kap-server): align archived session restore

This commit is contained in:
_Kerman 2026-07-09 20:21:52 +08:00
parent d2985e17dd
commit f91b34dc69
12 changed files with 218 additions and 25 deletions

View file

@ -95,6 +95,9 @@ export interface ISessionActivityKernel {
lane(): SessionLane;
/** Leaves the restore/materialize window and admits normal session commands. */
markActive(): void;
/** Admission table for edge (gateway / rpc / legacy) and `agentLifecycle` commands. */
canAccept(command: SessionCommand): boolean;

View file

@ -31,6 +31,10 @@ export class SessionActivityKernel extends Disposable implements ISessionActivit
return 'active';
}
markActive(): void {
// Placeholder kernel is already active.
}
canAccept(_command: SessionCommand): boolean {
return true;
}

View file

@ -4,7 +4,7 @@
* Defines the public contract of session lifecycle: the `CreateSessionOptions`,
* `ForkSessionOptions`, `CreateChildSessionOptions`, and the
* `ISessionLifecycleService` used to create sessions (`create`), look up the
* live ones (`get` / `list`), close them (`close`), archive them (`archive`),
* live ones (`get` / `list`), close them (`close`), archive/restore them,
* fork them (`fork`), and fork-then-tag them as direct children (`createChild`). Announces
* lifecycle transitions through ordered hook slots plus
* `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` /
@ -114,6 +114,7 @@ export interface ISessionLifecycleService {
resume(sessionId: string): Promise<ISessionScopeHandle | undefined>;
close(sessionId: string): Promise<void>;
archive(sessionId: string): Promise<void>;
restore(sessionId: string): Promise<ISessionScopeHandle | undefined>;
fork(opts: ForkSessionOptions): Promise<ISessionScopeHandle>;
/**
* Fork a session and tag it as a direct child of its source (writes the

View file

@ -5,7 +5,7 @@
* through the DI scope tree and seeding each with its identity and storage
* addressing, running lifecycle hook slots, and tearing them down on
* close/archive archiving flags the session's `sessionMetadata`, removes
* its `agentLifecycle` agents, and
* its `agentLifecycle` agents, restoring clears the archived flag, and
* broadcasts through `event`. Materializes the session's initial metadata on
* creation by resolving `sessionMetadata`. Bound at App scope. Persisted
* sessions are discovered through the `sessionIndex` read model, and workspace
@ -277,6 +277,13 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
this._onDidArchiveSession.fire({ sessionId });
}
async restore(sessionId: string): Promise<ISessionScopeHandle | undefined> {
const handle = await this.resume(sessionId);
if (handle === undefined) return undefined;
await handle.accessor.get(ISessionMetadata).setArchived(false);
return handle;
}
private async announceWillClose(event: SessionWillCloseEvent): Promise<void> {
await this.hooks.onWillCloseSession.run(event);
}

View file

@ -24,6 +24,7 @@ export function stubSessionActivityKernel(
return {
_serviceBrand: undefined,
lane: () => lane,
markActive: () => undefined,
canAccept: (_command: SessionCommand) => lane === 'active',
admitTurn(_agentId: string, lease: ActivityLease): IDisposable {
leases.add(lease);

View file

@ -186,6 +186,7 @@ function stubSessionLifecycle(): ISessionLifecycleService {
resume: async () => undefined,
close: async () => {},
archive: async () => {},
restore: async () => undefined,
fork: async () => {
throw new Error('not implemented');
},

View file

@ -325,6 +325,7 @@ function registerSessionExportServices(
resume: async () => options.lifecycleHandle,
close: async () => {},
archive: async () => {},
restore: async () => options.lifecycleHandle,
fork: async () => {
throw new Error('fork should not be called by session export');
},

View file

@ -11,6 +11,7 @@ import {
registerScopedService,
} from '#/_base/di/scope';
import { type ScopedTestHost, createScopedTestHost, stubPair } from '#/_base/di/test';
import { ISessionActivityKernel } from '#/activity/activity';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { IEventService } from '#/app/event/event';
@ -35,6 +36,7 @@ import { SessionWorkspaceContextService } from '#/session/workspaceContext/works
import { IWorkspaceRegistry, type Workspace } from '#/app/workspaceRegistry/workspaceRegistry';
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { stubSessionActivityKernel } from '../activity/stubs';
function bootstrapStub(): IBootstrapService {
return {
@ -331,6 +333,7 @@ describe('SessionLifecycleService', () => {
stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()),
stubPair(IEventService, eventStub()),
stubPair(IAgentLifecycleService, agentLifecycleStub()),
stubPair(ISessionActivityKernel, stubSessionActivityKernel()),
stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub()),
...extra,
]);
@ -485,6 +488,26 @@ describe('SessionLifecycleService', () => {
expect(svc.get('s1')).toBeUndefined();
});
it('restore clears the archived flag when the session exists on disk', async () => {
let archived: boolean | undefined;
const svc = build([
stubPair(ISessionIndex, sessionIndexWithSummary('s1', '/tmp/proj')),
stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()),
stubPair(ISessionMetadata, {
...metadataStub(),
setArchived: (value: boolean) => {
archived = value;
return Promise.resolve();
},
}),
]);
const restored = await svc.restore('s1');
expect(restored?.id).toBe('s1');
expect(archived).toBe(false);
});
it('fires onDidCreateSession with the new handle', async () => {
const svc = build();
let captured: { readonly sessionId: string } | undefined;

View file

@ -9,15 +9,15 @@
* GET /sessions/{session_id}/profile
* POST /sessions/{session_id}/profile update title / metadata / agent_config
* POST /sessions/{tail} action: fork / compact / undo /
* abort / btw / archive
* abort / btw / archive / restore
* GET /sessions/{session_id}/children list child sessions
* POST /sessions/{session_id}/children create child session (fork+tag)
* GET /sessions/{session_id}/status best-effort
* GET /sessions/{session_id}/warnings agents-md-oversized notice
*
* The `POST /sessions/{tail}` actions split into two groups. The thin
* pass-throughs `fork` / `compact` / `abort` / `archive` call the native v2
* services directly (`ISessionLifecycleService.fork` / `archive`,
* pass-throughs `fork` / `compact` / `abort` / `archive` / `restore` call
* the native v2 services directly (`ISessionLifecycleService.fork` / `archive` / `restore`,
* `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no
* v1-only projection to centralize, so no adapter is involved. `undo` likewise
* calls `IAgentPromptService.undo` directly (it now throws
@ -55,7 +55,9 @@
* is real on every session-producing endpoint here. `GET /sessions` and
* `GET /sessions/{id}/children` filter their projected page by the `status`
* query param (post-page, matching v1 `has_more` reflects the pre-filter
* page). The `aborted` phase is not derived yet (gap G10); v2 never reports it.
* page), except `archived_only` lists filter status before route pagination so
* they can drain archived pages the same way v1 does. The `aborted` phase is not
* derived yet (gap G10); v2 never reports it.
*
* **cwd resolution (gap G3 closed)**: the session's frozen work dir is
* persisted on its metadata document (`ISessionMetadata`) and surfaced on the
@ -145,6 +147,8 @@ const booleanQueryParam = z.preprocess((value) => {
return value;
}, z.boolean().optional());
const DEFAULT_SESSION_LIST_PAGE_SIZE = 20;
// NOTE: mirrors v1's `GET /sessions` query. `before_id`/`after_id` id-cursors
// and `page_size` ARE applied in the route handler (the `FileSessionIndex` does
// not implement `cursor`, so we page over its recency-sorted result); `status`
@ -365,18 +369,20 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
includeArchived: archivedOnly ? true : raw.include_archive,
});
// Filter down to the sequence the client actually sees BEFORE computing
// the cursor position and the page boundary, so a cursor carried over
// from a previous page always resolves to the same index. `cwd` is read
// from the session's own summary first (gap G3 closed — an unregistered
// workspace no longer drops the session); the registry `roots` map is
// only a back-compat fallback for sessions written before `cwd` was
// persisted. A session with no recoverable cwd is still skipped.
const eligible: { summary: (typeof page.items)[number]; cwd: string }[] = [];
// Filter down to the sequence the client can page over BEFORE computing
// the cursor position. `cwd` is read from the session's own summary first
// (gap G3 closed — an unregistered workspace no longer drops the session);
// the registry `roots` map is only a back-compat fallback for sessions
// written before `cwd` was persisted. A session with no recoverable cwd is
// still skipped.
const eligible: {
readonly summary: (typeof page.items)[number];
readonly cwd: string;
readonly status?: Session['status'];
}[] = [];
for (const summary of page.items) {
const cwd = summary.cwd ?? roots.get(summary.workspaceId);
if (cwd === undefined) continue;
if (archivedOnly && summary.archived !== true) continue;
if (raw.exclude_empty === true && (summary.lastPrompt ?? '').length === 0) continue;
eligible.push({ summary, cwd });
}
@ -399,18 +405,30 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
}
const window = eligible.slice(start, end);
const limit = pageSize ?? window.length;
const hasMore = window.length > limit;
const projected: Session[] = window
let visible = window;
if (archivedOnly) {
visible =
raw.status === undefined
? window.filter((entry) => entry.summary.archived === true)
: window.flatMap((entry) => {
if (entry.summary.archived !== true) return [];
const status = resolveSessionStatus(core, entry.summary.id);
return status === raw.status ? [{ ...entry, status }] : [];
});
}
const limit = archivedOnly
? (pageSize ?? DEFAULT_SESSION_LIST_PAGE_SIZE)
: (pageSize ?? visible.length);
const hasMore = visible.length > limit;
const projected: Session[] = visible
.slice(0, limit)
.map(({ summary, cwd }) =>
toWireSession(summary, cwd, resolveSessionStatus(core, summary.id)),
.map(({ summary, cwd, status }) =>
toWireSession(summary, cwd, status ?? resolveSessionStatus(core, summary.id)),
);
// v1 filters the projected page by `status` (post-page); `has_more` keeps
// reflecting the pre-filter page, so a filtered page may be short or empty
// while `has_more` is still true — match that exactly.
// v1 filters ordinary lists by `status` post-page; `archived_only` already
// applied status before pagination above so it can drain to a full page.
const items =
raw.status !== undefined
raw.status !== undefined && !archivedOnly
? projected.filter((session) => session.status === raw.status)
: projected;
reply.send(okEnvelope({ items, has_more: hasMore }, req.id));
@ -593,7 +611,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
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') {
@ -697,6 +715,22 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
return;
}
if (parsed.action === 'restore') {
const restored = await core.accessor.get(ISessionLifecycleService).restore(parsed.id);
if (restored === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`);
}
const meta = await restored.accessor.get(ISessionMetadata).read();
const ctx = restored.accessor.get(ISessionContext);
const session = toWireSession(
{ ...meta, workspaceId: ctx.workspaceId },
ctx.cwd,
resolveSessionStatus(core, meta.id),
);
reply.send(okEnvelope(session, req.id));
return;
}
// archive — `resume` (not `get`) so archiving a freshly-opened cold
// session still works; `resume` returns undefined only when the session
// is unknown or its workspace is gone, reported as `session.not_found`.

View file

@ -324,6 +324,28 @@ describe('server-v2 /api/v1/sessions', () => {
expect(got.body.data.archived).toBe(true);
});
it('restores an archived session via :restore and returns it to the default list', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const id = created.body.data.id;
await postJson<{ archived: boolean }>(`/api/v1/sessions/${id}:archive`);
const restored = await postJson<SessionWire>(`/api/v1/sessions/${id}:restore`);
expect(restored.body.code).toBe(0);
expect(restored.body.data.id).toBe(id);
expect(restored.body.data.archived).toBe(false);
const listed = await getJson<PageWire>('/api/v1/sessions');
expect(listed.body.code).toBe(0);
expect(listed.body.data.items.find((s) => s.id === id)?.archived).toBe(false);
});
it('returns 40401 when restoring a missing session', async () => {
const { body } = await postJson<null>('/api/v1/sessions/sess_missing:restore');
expect(body.code).toBe(40401);
});
it('cold-loads a persisted session on :undo instead of 40401', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
@ -482,6 +504,42 @@ describe('server-v2 /api/v1/sessions', () => {
expect(all.body.data.items.some((s) => s.id === archivedId)).toBe(true);
});
it('paginates archived_only without returning empty filtered pages', async () => {
const cwd = home as string;
const archivedOlder = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
await postJson<{ archived: boolean }>(
`/api/v1/sessions/${archivedOlder.body.data.id}:archive`,
);
const archivedNewer = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
await postJson<{ archived: boolean }>(
`/api/v1/sessions/${archivedNewer.body.data.id}:archive`,
);
await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const first = await getJson<PageWire>('/api/v1/sessions?archived_only=true&page_size=1');
expect(first.body.code).toBe(0);
expect(first.body.data.items).toHaveLength(1);
expect(first.body.data.items[0]).toMatchObject({
id: archivedNewer.body.data.id,
archived: true,
});
expect(first.body.data.has_more).toBe(true);
const second = await getJson<PageWire>(
`/api/v1/sessions?archived_only=true&page_size=1&before_id=${archivedNewer.body.data.id}`,
);
expect(second.body.code).toBe(0);
expect(second.body.data.items).toHaveLength(1);
expect(second.body.data.items[0]).toMatchObject({
id: archivedOlder.body.data.id,
archived: true,
});
expect(second.body.data.has_more).toBe(false);
});
it('rejects archived_only combined with include_archive (40001)', async () => {
const { body } = await getJson<null>(
'/api/v1/sessions?archived_only=true&include_archive=true',
@ -489,6 +547,21 @@ describe('server-v2 /api/v1/sessions', () => {
expect(body.code).toBe(40001);
});
it('returns a terminal empty page when archived_only status filtering finds no match', async () => {
const cwd = home as string;
const first = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const second = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
await postJson<{ archived: boolean }>(`/api/v1/sessions/${first.body.data.id}:archive`);
await postJson<{ archived: boolean }>(`/api/v1/sessions/${second.body.data.id}:archive`);
const page = await getJson<PageWire>(
'/api/v1/sessions?archived_only=true&status=running&page_size=1',
);
expect(page.body.code).toBe(0);
expect(page.body.data).toEqual({ items: [], has_more: false });
});
it('rejects a malformed workspace_id when listing (40001)', async () => {
const { body } = await getJson<null>('/api/v1/sessions?workspace_id=not-a-workspace-id');
expect(body.code).toBe(40001);

View file

@ -14,6 +14,7 @@ import {
listSessionChildrenQuerySchema,
listSessionChildrenResponseSchema,
listSessionsQuerySchema,
restoreSessionResponseSchema,
sessionStatusResponseSchema,
updateSessionProfileRequestSchema,
updateSessionRequestSchema,
@ -100,6 +101,15 @@ describe('listSessionsQuerySchema', () => {
});
});
it('parses archived_only to boolean', () => {
expect(listSessionsQuerySchema.parse({ archived_only: 'true' })).toEqual({
archived_only: true,
});
expect(listSessionsQuerySchema.parse({ archived_only: false })).toEqual({
archived_only: false,
});
});
it('parses exclude_empty to boolean', () => {
expect(listSessionsQuerySchema.parse({ exclude_empty: 'true' })).toEqual({
exclude_empty: true,
@ -498,6 +508,36 @@ describe('archiveSessionResponseSchema', () => {
});
});
describe('restoreSessionResponseSchema', () => {
it('accepts a restored Session payload', () => {
const parsed = restoreSessionResponseSchema.parse({
id: 'sess_abc',
workspace_id: 'wd_kimi_0123456789ab',
title: 'Restored',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
status: 'idle',
archived: false,
metadata: { cwd: '/tmp/foo' },
agent_config: { model: '' },
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_cost_usd: 0,
context_tokens: 0,
context_limit: 0,
turn_count: 0,
},
permission_rules: [],
message_count: 0,
last_seq: 0,
});
expect(parsed.archived).toBe(false);
});
});
describe('deleteSessionResponseSchema (deprecated alias)', () => {
it('accepts the canonical { archived: true } shape', () => {
expect(deleteSessionResponseSchema.parse({ archived: true })).toEqual({ archived: true });

View file

@ -12,6 +12,7 @@
* POST /v1/sessions/{id}:compact body: CompactSession data: {}
* POST /v1/sessions/{id}:undo body: UndoSession data: UndoSession
* POST /v1/sessions/{id}:archive - data: { archived: true }
* POST /v1/sessions/{id}:restore - data: Session
*/
import { z } from 'zod';
@ -46,6 +47,7 @@ export const listSessionsQuerySchema = cursorQuerySchema.and(
z.object({
status: sessionStatusSchema.optional(),
include_archive: booleanQueryParam,
archived_only: booleanQueryParam,
exclude_empty: booleanQueryParam,
}),
);
@ -161,6 +163,9 @@ export const archiveSessionResponseSchema = z.object({
});
export type ArchiveSessionResponse = z.infer<typeof archiveSessionResponseSchema>;
export const restoreSessionResponseSchema = sessionSchema;
export type RestoreSessionResponse = z.infer<typeof restoreSessionResponseSchema>;
/** @deprecated kept as an alias for backward compatibility; prefer archiveSessionResponseSchema. */
export const deleteSessionResponseSchema = archiveSessionResponseSchema;
/** @deprecated kept as an alias for backward compatibility; prefer ArchiveSessionResponse. */