diff --git a/docs/en/reference/server-api.md b/docs/en/reference/server-api.md index 3a056830d..9f6a9bae9 100644 --- a/docs/en/reference/server-api.md +++ b/docs/en/reference/server-api.md @@ -77,7 +77,7 @@ Error codes are grouped by band: List endpoints come in two styles: - **Cursor style**: `before_id` / `after_id` (mutually exclusive) plus `page_size` (1–100), responding with `{ items, has_more }`. Used by the session list, message list, transcript, and others. -- **`page_token`**: an opaque token (bound to a fingerprint of the query conditions), used by `POST /api/v1/search` and `GET /api/v2/sessions`. Changing any query condition mid-pagination invalidates the token: v2 returns `40922`, search returns `40001`. +- **`page_token`**: an opaque token (bound to a fingerprint of the query conditions), used by `POST /api/v1/search` and `GET /api/v2/sessions`. Changing any query condition mid-pagination invalidates the token: v2 returns `40922`, search returns `40001`. `GET /api/v2/sessions` also offers a stateless `page` page-number mode as an alternative. ## REST endpoints @@ -245,6 +245,8 @@ In-session file operations go through `POST /api/v1/sessions/{session_id}/fs:{ac | `POST /api/v1/search` | Cross-session full-text search; `mode` is `terms` (default) or `literal` (exact substring); `page_token` pagination | | `GET /api/v1/connections` | List live WebSocket connections | | `GET /api/v2/sessions` | Next-generation session list, see below | +| `POST /api/v2/sessions:archive` | Batch-archive sessions, see below | +| `POST /api/v2/sessions:restore` | Batch-restore archived sessions, see below | | `/api/v1/debug/*` | Reflection debug RPC; mounted only with `--debug-endpoints` on loopback, not a stable protocol | ### `GET /api/v2/sessions` @@ -256,13 +258,38 @@ A next-generation session query for list views — filtering, sorting, and field | `workspace.id` | Filter by workspace; repeatable | | `activity.status` | Filter by activity status: `running` / `approval` / `question` / `failed` / `idle`; repeatable | | `meta.updated_after` | Only sessions updated after this time (epoch milliseconds) | +| `meta.updated_before` | Only sessions updated before this time (epoch milliseconds) | | `meta.archived` | `true` / `false` (default) / `all` | | `sort` | `meta.updated_at_desc` (default) / `meta.updated_at_asc` / `meta.created_at_desc` | | `include` | Comma-separated extra field groups; currently only `git` (branch and PR info, deduplicated per directory and cached for 60 seconds) | -| `page_size` | 1–100, default 50 | +| `fields` | Comma-separated item projection; currently only `id,archived`, trimming each item to `{ id, archived }` (select-all-matching flows). Not combinable with `include=git` (`40001`) | +| `page_size` | 1–100, default 50; up to 10000 with the `id,archived` projection | | `page_token` | Pagination token from the previous page | +| `page` | Stateless 1-based page number; mutually exclusive with `page_token` (`40001` when combined) | -Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git`. The page token binds the first page's query conditions; changing them mid-pagination returns `40922`. +Every response item carries the `workspace`, `meta`, and `activity` groups, plus `git` when `include=git` — or just `{ id, archived }` under `fields=id,archived`. Every page additionally carries `total`, the size of the filtered set. The page token binds the first page's query conditions (including the projection); changing them mid-pagination returns `40922`. `page` mode is a stateless alternative for jumping to arbitrary pages: every request is an independent snapshot, no token is minted, and `next_page_token` is always `null`. + +### `POST /api/v2/sessions:archive` and `POST /api/v2/sessions:restore` + +Batch archive/restore for session-management views. The body is `{ "ids": ["session_..."] }` — non-empty, at most 5000 unique ids (duplicates collapse). Live sessions go through the full lifecycle; cold sessions are patched on disk without being loaded. + +Only a body validation failure fails the whole request (`40001`). Otherwise the response is per-item: `data.results` keeps the input order with `{ id, ok }` or `{ id, ok: false, error }` (an unknown id reports `40401` in its own item), plus `succeeded` / `failed` counts. + +```json +{ + "code": 0, + "msg": "success", + "data": { + "results": [ + { "id": "session_a", "ok": true }, + { "id": "session_b", "ok": false, "error": { "code": 40401, "message": "session session_b does not exist" } } + ], + "succeeded": 1, + "failed": 1 + }, + "request_id": "req_..." +} +``` ## WebSocket protocol diff --git a/docs/zh/reference/server-api.md b/docs/zh/reference/server-api.md index 9ff6758e6..4a8ecde20 100644 --- a/docs/zh/reference/server-api.md +++ b/docs/zh/reference/server-api.md @@ -77,7 +77,7 @@ HTTP 状态码几乎总是 200,业务结果以 `code` 为准。例外情况: 列表端点有两种分页风格: - **游标式**:`before_id` / `after_id`(互斥)加 `page_size`(1–100),响应为 `{ items, has_more }`。用于会话列表、消息列表、转录等。 -- **`page_token`**:不透明令牌(内部绑定了查询条件指纹),用于 `POST /api/v1/search` 与 `GET /api/v2/sessions`。翻页途中改变任何查询条件会使令牌失效:v2 返回 `40922`,search 返回 `40001`。 +- **`page_token`**:不透明令牌(内部绑定了查询条件指纹),用于 `POST /api/v1/search` 与 `GET /api/v2/sessions`。翻页途中改变任何查询条件会使令牌失效:v2 返回 `40922`,search 返回 `40001`。`GET /api/v2/sessions` 另提供无状态的 `page` 页码模式作为替代。 ## REST 端点 @@ -245,6 +245,8 @@ PTY 终端接口,仅 loopback 绑定时挂载。 | `POST /api/v1/search` | 跨会话全文搜索,`mode` 为 `terms`(默认)或 `literal`(精确子串),`page_token` 分页 | | `GET /api/v1/connections` | 列出当前在线的 WebSocket 连接 | | `GET /api/v2/sessions` | 新一代会话列表,见下节 | +| `POST /api/v2/sessions:archive` | 批量归档会话,见下节 | +| `POST /api/v2/sessions:restore` | 批量恢复已归档会话,见下节 | | `/api/v1/debug/*` | 反射式调试 RPC,仅 `--debug-endpoints` 且 loopback 时挂载,不属于稳定协议 | ### `GET /api/v2/sessions` @@ -256,13 +258,38 @@ PTY 终端接口,仅 loopback 绑定时挂载。 | `workspace.id` | 按工作区过滤,可重复 | | `activity.status` | 按活动状态过滤:`running` / `approval` / `question` / `failed` / `idle`,可重复 | | `meta.updated_after` | 只看该时间(epoch 毫秒)之后更新过的会话 | +| `meta.updated_before` | 只看该时间(epoch 毫秒)之前更新过的会话 | | `meta.archived` | `true` / `false`(默认)/ `all` | | `sort` | `meta.updated_at_desc`(默认)/ `meta.updated_at_asc` / `meta.created_at_desc` | | `include` | 逗号分隔的附加字段组;目前支持 `git`(分支与 PR 信息,按目录去重并缓存 60 秒) | -| `page_size` | 1–100,默认 50 | +| `fields` | 逗号分隔的字段投影;目前仅支持 `id,archived`,每项裁剪为 `{ id, archived }`(用于全选匹配场景)。不可与 `include=git` 同传(`40001`) | +| `page_size` | 1–100,默认 50;使用 `id,archived` 投影时上限放宽至 10000 | | `page_token` | 上一页返回的翻页令牌 | +| `page` | 无状态的 1 起始页码;与 `page_token` 互斥(同传返回 `40001`) | -响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组。翻页令牌绑定首页查询条件,中途改条件返回 `40922`。 +响应每项固定包含 `workspace`、`meta`、`activity` 三组,`include=git` 时附加 `git` 组;`fields=id,archived` 时仅返回 `{ id, archived }`。每页额外携带 `total`,即过滤后的集合大小。翻页令牌绑定首页查询条件(含投影),中途改条件返回 `40922`。`page` 模式是跳页用的无状态替代:每次请求都是独立快照,不签发令牌,`next_page_token` 恒为 `null`。 + +### `POST /api/v2/sessions:archive` 与 `POST /api/v2/sessions:restore` + +面向会话管理页的批量归档/恢复。请求体为 `{ "ids": ["session_..."] }`——非空、去重后不超过 5000 条。仍在线的会话走完整生命周期;未加载的冷会话直接改写磁盘上的元数据,不会被加载。 + +只有请求体校验失败才会让整个请求失败(`40001`);其余情况按条返回:`data.results` 保持输入顺序,每项为 `{ id, ok }` 或 `{ id, ok: false, error }`(不存在的 id 在自身条目里报 `40401`),并附 `succeeded` / `failed` 计数。 + +```json +{ + "code": 0, + "msg": "success", + "data": { + "results": [ + { "id": "session_a", "ok": true }, + { "id": "session_b", "ok": false, "error": { "code": 40401, "message": "session session_b does not exist" } } + ], + "succeeded": 1, + "failed": 1 + }, + "request_id": "req_..." +} +``` ## WebSocket 协议 diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts index e1d3cc3d6..d95de7e4c 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManager.ts @@ -18,6 +18,11 @@ export interface CreateManagedSessionOptions extends CreateSessionOptions { readonly workspaceId?: string; } +export interface UnguardedSessionLifecycle { + archive(): Promise; + restore(): Promise; +} + export interface ISessionManager { readonly _serviceBrand: undefined; readonly onWillCreateSession?: Event; @@ -29,6 +34,11 @@ export interface ISessionManager { create(options: CreateManagedSessionOptions): Promise; resume(sessionId: string, options?: ResumeSessionOptions): Promise; get(sessionId: string): ISessionScopeHandle | undefined; + whenResumeSettled(sessionId: string): Promise; + withLifecycleSerialization( + sessionId: string, + work: (unguarded: UnguardedSessionLifecycle) => Promise, + ): Promise; list(): readonly ISessionScopeHandle[]; close(sessionId: string): Promise; archive(sessionId: string): Promise; diff --git a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts index 4d518a8ed..3b591b393 100644 --- a/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts +++ b/packages/agent-core-v2/src/app/sessionManager/sessionManagerService.ts @@ -1,3 +1,4 @@ + import { DisposableStore } from '#/_base/di/lifecycle'; import { Emitter, type Event, type IWaitUntil } from '#/_base/event'; import { ScopeActivation, registerScopedService, type ISessionScopeHandle } from '#/_base/di/scope'; @@ -18,7 +19,11 @@ import { import type { SessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycleService'; import { IWorkspaceInstanceManager } from '#/workspace/workspaceInstance/workspaceInstanceManager'; -import { ISessionManager, type CreateManagedSessionOptions } from './sessionManager'; +import { + ISessionManager, + type CreateManagedSessionOptions, + type UnguardedSessionLifecycle, +} from './sessionManager'; interface SessionControllerEntry { readonly generation: string; @@ -31,6 +36,9 @@ export class SessionManager implements ISessionManager { declare readonly _serviceBrand: undefined; private readonly sessions = new Map(); private readonly owners = new Map(); + private readonly pendingResumes = new Map>(); + private readonly resumeFailures = new Map(); + private readonly lifecycleChains = new Map>(); private readonly controllers = new Map(); private readonly controllerEntries = new Set(); private readonly willCreateEmitter = new Emitter(); @@ -57,61 +65,139 @@ export class SessionManager implements ISessionManager { ? { root: options.workDir } : { workspaceId: options.workspaceId, root: options.workDir }, ); - return this.controllerForWorkspace(workspace.id).create(options); + const controller = this.controllerForWorkspace(workspace.id); + if (options.sessionId === undefined) return controller.create(options); + return this.serializeLifecycle(options.sessionId, () => controller.create(options)); } async resume(sessionId: string, options?: ResumeSessionOptions): Promise { - return (await this.controllerForSession(sessionId))?.resume(sessionId, options); + const inflight = this.pendingResumes.get(sessionId); + if (inflight !== undefined) return inflight; + this.resumeFailures.delete(sessionId); + const promise = this.serializeLifecycle(sessionId, async () => + (await this.controllerForSession(sessionId))?.resume(sessionId, options), + ).finally(() => this.pendingResumes.delete(sessionId)); + this.pendingResumes.set(sessionId, promise); + void promise.catch((error: unknown) => { + this.resumeFailures.set(sessionId, error instanceof Error ? error : new Error('session resume failed')); + }); + return promise; } get(sessionId: string): ISessionScopeHandle | undefined { return this.sessions.get(sessionId); } + async whenResumeSettled(sessionId: string): Promise { + await this.pendingResumes.get(sessionId); + const failure = this.resumeFailures.get(sessionId); + if (failure !== undefined) throw failure; + await this.owners.get(sessionId)?.whenResumeSettled(sessionId); + } + + private serializeLifecycle(sessionId: string, work: () => Promise): Promise { + const prev = this.lifecycleChains.get(sessionId) ?? Promise.resolve(); + const run = prev.then(work, work); + const next = run.then( + () => undefined, + () => undefined, + ); + this.lifecycleChains.set(sessionId, next); + void next.finally(() => { + if (this.lifecycleChains.get(sessionId) === next) this.lifecycleChains.delete(sessionId); + }); + return run; + } + + private serializeLifecycleForKeys(keys: readonly string[], work: () => Promise): Promise { + const [first, ...rest] = keys; + if (first === undefined) return work(); + return this.serializeLifecycle(first, () => this.serializeLifecycleForKeys(rest, work)); + } + + private lifecycleKeys(...ids: (string | undefined)[]): string[] { + return [...new Set(ids.filter((id): id is string => id !== undefined))].sort(); + } + + withLifecycleSerialization( + sessionId: string, + work: (unguarded: UnguardedSessionLifecycle) => Promise, + ): Promise { + return this.serializeLifecycle(sessionId, () => + work({ + archive: () => this.archiveInner(sessionId), + restore: () => this.restoreInner(sessionId), + }), + ); + } + list(): readonly ISessionScopeHandle[] { return [...this.sessions.values()]; } async close(sessionId: string): Promise { - await this.owners.get(sessionId)?.close(sessionId); + await this.serializeLifecycle(sessionId, async () => this.owners.get(sessionId)?.close(sessionId)); } - async archive(sessionId: string): Promise { + private async archiveInner(sessionId: string): Promise { await (await this.controllerForSession(sessionId))?.archive(sessionId); } - async restore(sessionId: string, options?: ResumeSessionOptions): Promise { + async archive(sessionId: string): Promise { + await this.serializeLifecycle(sessionId, () => this.archiveInner(sessionId)); + } + + private async restoreInner( + sessionId: string, + options?: ResumeSessionOptions, + ): Promise { return (await this.controllerForSession(sessionId))?.restore(sessionId, options); } + async restore(sessionId: string, options?: ResumeSessionOptions): Promise { + return this.serializeLifecycle(sessionId, () => this.restoreInner(sessionId, options)); + } + async delete(sessionId: string): Promise { - const controller = await this.controllerForSession(sessionId); - if (controller === undefined) { - throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); - } - await controller.delete(sessionId); + await this.serializeLifecycle(sessionId, async () => { + const controller = await this.controllerForSession(sessionId); + if (controller === undefined) { + throw new Error2(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`); + } + await controller.delete(sessionId); + }); } async fork(options: ForkSessionOptions): Promise { - const controller = await this.controllerForSession(options.sourceSessionId); - if (controller === undefined) { - throw new Error2( - ErrorCodes.SESSION_NOT_FOUND, - `session ${options.sourceSessionId} does not exist`, - ); - } - return controller.fork(options); + return this.serializeLifecycleForKeys( + this.lifecycleKeys(options.sourceSessionId, options.newSessionId), + async () => { + const controller = await this.controllerForSession(options.sourceSessionId); + if (controller === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `session ${options.sourceSessionId} does not exist`, + ); + } + return controller.fork(options); + }, + ); } async createChild(options: CreateChildSessionOptions): Promise { - const controller = await this.controllerForSession(options.sourceSessionId); - if (controller === undefined) { - throw new Error2( - ErrorCodes.SESSION_NOT_FOUND, - `session ${options.sourceSessionId} does not exist`, - ); - } - return controller.createChild(options); + return this.serializeLifecycleForKeys( + this.lifecycleKeys(options.sourceSessionId, options.newSessionId), + async () => { + const controller = await this.controllerForSession(options.sourceSessionId); + if (controller === undefined) { + throw new Error2( + ErrorCodes.SESSION_NOT_FOUND, + `session ${options.sourceSessionId} does not exist`, + ); + } + return controller.createChild(options); + }, + ); } dispose(): void { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index abbbbfeed..64bce94c6 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -451,6 +451,7 @@ export * from '#/workspace/workspaceContext/workspaceContext'; export * from '#/workspace/sessionLifecycle/sessionLifecycle'; export * from '#/workspace/sessionLifecycle/sessionLifecycleEvents'; export * from '#/workspace/sessionLifecycle/sessionLifecycleService'; +export * from '#/workspace/sessionLifecycle/coldSessionArchive'; export * from '#/workspace/sessionLifecycle/internal/addressing'; export * from '#/session/externalHooks/externalHooks'; export * from '#/session/externalHooks/externalHooksService'; diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index ea747344f..e26aa4643 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -292,7 +292,7 @@ function isSessionTitleKind(value: unknown): value is SessionTitleKind { type PersistedSessionMeta = SessionMeta & { readonly isCustomTitle: boolean }; -function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta { +export function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta { return { ...meta, isCustomTitle: meta.titleKind === 'custom' }; } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts new file mode 100644 index 000000000..21cffc97c --- /dev/null +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/coldSessionArchive.ts @@ -0,0 +1,112 @@ + +import type { ServicesAccessor } from '#/_base/di/instantiation'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IEventService } from '#/app/event/event'; +import { ISessionManager } from '#/app/sessionManager/sessionManager'; +import { getLiveSessionById } from '#/app/sessionManager/sessionLookup'; +import { ISessionIndex, ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex'; +import { buildSessionSummary } from '#/app/sessionIndex/sessionIndexSource'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; +import { normalizeSessionMeta, encodeSessionMeta } from '#/session/sessionMetadata/sessionMetadataService'; + +import { sessionScopeOf, legacySessionMetaScopeOf, workspacePersistenceScope } from './internal/addressing'; +import { SessionArchived } from './sessionLifecycleEvents'; + +export type ColdSessionArchiveOutcome = 'updated' | 'not_found'; + +export async function setColdSessionArchived( + accessor: ServicesAccessor, + sessionId: string, + archived: boolean, +): Promise { + const summary = await accessor.get(ISessionIndex).get(sessionId); + if (summary === undefined) return 'not_found'; + const docs = accessor.get(IAtomicDocumentStore); + const metaScope = sessionScopeOf( + workspacePersistenceScope( + accessor.get(IBootstrapService).scope('sessions'), + summary.workspaceId, + ), + sessionId, + ); + let raw = await docs.get(metaScope, 'state.json'); + let legacyMetaScope: string | undefined; + if (raw === undefined) { + legacyMetaScope = legacySessionMetaScopeOf(metaScope); + raw = await docs.get(legacyMetaScope, 'state.json'); + } + if (raw === undefined) return 'not_found'; + const persisted = normalizeSessionMeta(raw, sessionId); + const archivedAt = archived ? Date.now() : undefined; + const nextMeta: SessionMeta = { ...persisted, archived, archivedAt }; + await docs.set(metaScope, 'state.json', encodeSessionMeta(nextMeta)); + if (legacyMetaScope !== undefined) await docs.delete(legacyMetaScope, 'state.json'); + accessor.get(ISessionIndexMirror).record( + buildSessionSummary({ + id: sessionId, + workspaceId: summary.workspaceId, + cwd: nextMeta.cwd ?? summary.cwd, + title: nextMeta.title, + lastPrompt: nextMeta.lastPrompt, + createdAt: nextMeta.createdAt, + updatedAt: nextMeta.updatedAt, + archived, + archivedAt, + custom: nextMeta.custom, + lastTurnReason: nextMeta.lastTurnReason, + }), + ); + if (archived) { + accessor.get(IEventService).publish(new SessionArchived({ payload: { sessionId } })); + } + return 'updated'; +} + +export type SessionArchiveBatchItemOutcome = + | { id: string; ok: true } + | { id: string; ok: false; reason: 'not_found' | 'error'; message: string }; + +export async function setSessionArchivedBatch( + accessor: ServicesAccessor, + ids: readonly string[], + archived: boolean, +): Promise { + const outcomes: (SessionArchiveBatchItemOutcome | undefined)[] = ids.map(() => undefined); + const applyOne = async (id: string): Promise => { + try { + const manager = accessor.get(ISessionManager); + return await manager.withLifecycleSerialization(id, async (unguarded) => { + await manager.whenResumeSettled(id); + const live = getLiveSessionById(accessor, id); + if (live !== undefined) { + if (archived) await unguarded.archive(); + else await unguarded.restore(); + return { id, ok: true }; + } + const outcome = await setColdSessionArchived(accessor, id, archived); + return outcome === 'updated' + ? { id, ok: true } + : { id, ok: false, reason: 'not_found', message: `session ${id} does not exist` }; + }); + } catch (error) { + return { + id, + ok: false, + reason: 'error', + message: error instanceof Error ? error.message : String(error), + }; + } + }; + + const BATCH_CONCURRENCY = 8; + let next = 0; + const workers = Array.from({ length: Math.min(BATCH_CONCURRENCY, ids.length) }, async () => { + while (next < ids.length) { + const index = next++; + outcomes[index] = await applyOne(ids[index] as string); + } + }); + await Promise.all(workers); + return outcomes as SessionArchiveBatchItemOutcome[]; +} diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/addressing.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/addressing.ts index b158c2b97..f9c4876e6 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/addressing.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/addressing.ts @@ -15,3 +15,7 @@ export function sessionDirOf(homeDir: string, handlerScope: string, sessionId: s export function agentScopeOf(sessionScope: string, agentId: string): string { return `${sessionScope}/agents/${agentId}`; } + +export function legacySessionMetaScopeOf(sessionScope: string): string { + return `${sessionScope}/session-meta`; +} diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 8445f83b7..be88aa77f 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -133,6 +133,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; private readonly resuming = new Map>(); + private readonly resumeFailures = new Map(); constructor( private readonly instantiation: IInstantiationService, @@ -311,6 +312,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec if (inflight !== undefined) return inflight; const live = this.sessions.get(sessionId); if (live !== undefined) return Promise.resolve(live); + this.resumeFailures.delete(sessionId); const promise = this.doResume(sessionId, opts) .catch((error: unknown) => { this.telemetry @@ -318,6 +320,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec .track2('session_load_failed', { reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', }); + this.resumeFailures.set(sessionId, error instanceof Error ? error : new Error('session resume failed')); throw error; }) .finally(() => this.resuming.delete(sessionId)); @@ -325,6 +328,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec return promise; } + async whenResumeSettled(sessionId: string): Promise { + await this.resuming.get(sessionId); + const failure = this.resumeFailures.get(sessionId); + if (failure !== undefined) throw failure; + } + private async doResume( sessionId: string, opts?: ResumeSessionOptions, @@ -342,11 +351,17 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec additionalDirs: opts?.additionalDirs, mcpServers: opts?.mcpServers, }); - const agents = handle.accessor.get(IAgentLifecycleService); - if (agents.get(MAIN_AGENT_ID) === undefined) { - await agents.create({ agentId: MAIN_AGENT_ID }); + try { + const agents = handle.accessor.get(IAgentLifecycleService); + if (agents.get(MAIN_AGENT_ID) === undefined) { + await agents.create({ agentId: MAIN_AGENT_ID }); + } + await this.announceCreated({ sessionId, handle, source: 'resume' }); + } catch (error) { + this.sessions.delete(sessionId); + handle.dispose(); + throw error; } - await this.announceCreated({ sessionId, handle, source: 'resume' }); return handle; } diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 32f1063ad..a131d2c32 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -44,7 +44,7 @@ import { } from '#/app/sessionExport/sessionExportService'; import { writeExportZip } from '#/app/sessionExport/zip'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { ISessionManager } from '#/app/sessionManager/sessionManager'; +import { ISessionManager, type UnguardedSessionLifecycle } from '#/app/sessionManager/sessionManager'; import { ISessionLifecycleService } from '#/workspace/sessionLifecycle/sessionLifecycle'; import { IWorkspaceService } from '#/app/workspace/workspace'; import { Error2 } from '#/errors'; @@ -890,6 +890,11 @@ function registerSessionExportServices( }, resume: async () => options.lifecycleHandle, get: () => options.lifecycleHandle, + whenResumeSettled: async () => {}, + withLifecycleSerialization: async ( + _sessionId: string, + work: (unguarded: UnguardedSessionLifecycle) => Promise, + ): Promise => work({ archive: async () => {}, restore: async () => undefined }), list: () => (options.lifecycleHandle === undefined ? [] : [options.lifecycleHandle]), close: async () => {}, archive: async () => {}, diff --git a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts index f405cf943..20f635f66 100644 --- a/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts +++ b/packages/agent-core-v2/test/app/sessionManager/sessionManagerService.test.ts @@ -51,7 +51,366 @@ function controller(sessionId = 'session-1'): { return { service, handle }; } +async function drainMicrotasks(ticks = 50): Promise { + for (let i = 0; i < ticks; i++) await Promise.resolve(); +} + describe('SessionManager', () => { + it('serializes resume, close, and lifecycle critical sections per session', async () => { + const didCreate = new Emitter(); + const didClose = new Emitter(); + const handle = { id: 'session-1' } as unknown as ISessionScopeHandle; + let releaseResume!: () => void; + const resumeGate = new Promise((resolve) => { + releaseResume = resolve; + }); + const order: string[] = []; + const service = { + onWillCreateSession: Event.None, + onDidCreateSession: didCreate.event, + onWillCloseSession: Event.None, + onDidCloseSession: didClose.event, + onDidArchiveSession: Event.None, + onDidForkSession: Event.None, + create: async () => handle, + get: () => undefined, + list: () => [], + resume: async () => { + order.push('resume:start'); + await resumeGate; + didCreate.fire({ sessionId: 'session-1', handle, source: 'startup' }); + order.push('resume:end'); + return handle; + }, + close: async () => { + order.push('close'); + didClose.fire({ sessionId: 'session-1' }); + }, + archive: async () => {}, + restore: async () => handle, + delete: async () => {}, + fork: async () => handle, + createChild: async () => handle, + dispose: () => {}, + } as unknown as SessionLifecycleService; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + const resumePromise = manager.resume('session-1'); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section'); + }); + const closePromise = manager.close('session-1'); + await drainMicrotasks(); + expect(order).toEqual(['resume:start']); + releaseResume(); + await Promise.all([resumePromise, section, closePromise]); + expect(order).toEqual(['resume:start', 'resume:end', 'section', 'close']); + manager.dispose(); + }); + + it('holds a resume started during a lifecycle critical section', async () => { + const fake = controller(); + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const order: string[] = []; + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const resumePromise = manager.resume('session-1').then((handle) => { + order.push('resume'); + return handle; + }); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, resumePromise]); + expect(order).toEqual(['section:start', 'section:end', 'resume']); + manager.dispose(); + }); + + it('serializes delete with the per-session lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { delete: () => Promise }).delete = async () => { + order.push('delete'); + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const deletePromise = manager.delete('session-1'); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, deletePromise]); + expect(order).toEqual(['section:start', 'section:end', 'delete']); + manager.dispose(); + }); + + it('serializes fork of the source session with the lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { fork: () => Promise }).fork = async () => { + order.push('fork'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const forkPromise = manager.fork({ sourceSessionId: 'session-1' } as never); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, forkPromise]); + expect(order).toEqual(['section:start', 'section:end', 'fork']); + manager.dispose(); + }); + + it('serializes fork of an explicit target id with the lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { fork: () => Promise }).fork = async () => { + order.push('fork'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-2', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const forkPromise = manager.fork({ sourceSessionId: 'session-1', newSessionId: 'session-2' } as never); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, forkPromise]); + expect(order).toEqual(['section:start', 'section:end', 'fork']); + manager.dispose(); + }); + + it('serializes createChild of an explicit target id with the lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { createChild: () => Promise }).createChild = async () => { + order.push('createChild'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-2', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const childPromise = manager.createChild({ sourceSessionId: 'session-1', newSessionId: 'session-2' } as never); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, childPromise]); + expect(order).toEqual(['section:start', 'section:end', 'createChild']); + manager.dispose(); + }); + + it('serializes create with an explicit session id with the lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { create: () => Promise }).create = async () => { + order.push('create'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const createPromise = manager.create({ sessionId: 'session-1', workDir: '/workspace' } as never); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, createPromise]); + expect(order).toEqual(['section:start', 'section:end', 'create']); + manager.dispose(); + }); + + it('serializes archive with the per-session lifecycle chain', async () => { + const order: string[] = []; + const fake = controller(); + (fake.service as unknown as { archive: () => Promise }).archive = async () => { + order.push('archive'); + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + let releaseSection!: () => void; + const sectionGate = new Promise((resolve) => { + releaseSection = resolve; + }); + const section = manager.withLifecycleSerialization('session-1', async () => { + order.push('section:start'); + await sectionGate; + order.push('section:end'); + }); + const archivePromise = manager.archive('session-1'); + await drainMicrotasks(); + expect(order).toEqual(['section:start']); + releaseSection(); + await Promise.all([section, archivePromise]); + expect(order).toEqual(['section:start', 'section:end', 'archive']); + manager.dispose(); + }); + + it('propagates a failed resume to the next settle until a fresh attempt supersedes', async () => { + let fail = true; + const fake = controller(); + (fake.service as unknown as { resume: () => Promise }).resume = async () => { + if (fail) throw new Error('boom'); + return fake.handle; + }; + const workspace = { + id: 'workspace-1', + program: { sessionControllerGeneration: 'generation-1', createSessionController: () => fake.service }, + } as unknown as WorkspaceInstance; + const workspaces = { + getOrCreate: async () => workspace, + get: () => workspace, + } as unknown as IWorkspaceInstanceManager; + const index = { + get: async () => ({ workspaceId: 'workspace-1', cwd: '/workspace' }), + } as unknown as ISessionIndex; + const manager = new SessionManager(workspaces, index); + + await expect(manager.resume('session-1')).rejects.toThrow('boom'); + await expect(manager.whenResumeSettled('session-1')).rejects.toThrow('boom'); + + fail = false; + await manager.resume('session-1'); + await expect(manager.whenResumeSettled('session-1')).resolves.toBeUndefined(); + manager.dispose(); + }); + it('owns one global live-session registry across workspace controllers', async () => { const fake = controller(); const workspace = { diff --git a/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts new file mode 100644 index 000000000..b8467aa3e --- /dev/null +++ b/packages/agent-core-v2/test/workspace/sessionLifecycle/coldSessionArchive.test.ts @@ -0,0 +1,209 @@ + +import { describe, expect, it } from 'vitest'; + +import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IEventService } from '#/app/event/event'; +import { ISessionManager, type UnguardedSessionLifecycle } from '#/app/sessionManager/sessionManager'; +import { + ISessionIndex, + ISessionIndexMirror, + type SessionSummary, +} from '#/app/sessionIndex/sessionIndex'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import type { SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; +import { + setSessionArchivedBatch, +} from '#/workspace/sessionLifecycle/coldSessionArchive'; + +function accessor( + entries: ReadonlyArray, unknown]>, +): ServicesAccessor { + return { + get(id: ServiceIdentifier): T { + for (const [key, value] of entries) { + if (key === id) return value as T; + } + throw new Error(`Unexpected service request: ${String(id)}`); + }, + }; +} + +const summary: SessionSummary = { + id: 's1', + workspaceId: 'wd', + cwd: '/workspace', + createdAt: 1, + updatedAt: 1, + archived: false, +}; + +interface ColdPathOptions { + readonly storeGet: (scope: string, key: string) => Promise; + readonly indexSummary?: SessionSummary; + readonly onMirrorRecord?: (recorded: SessionSummary) => void; + readonly onStoreSet?: (scope: string, key: string, value: unknown) => void; + readonly onStoreDelete?: (scope: string, key: string) => void; + readonly resumeError?: Error; +} + +function coldPathAccessor(options: ColdPathOptions): ServicesAccessor { + return accessor([ + [ + ISessionManager, + { + withLifecycleSerialization: ( + _id: string, + work: (unguarded: UnguardedSessionLifecycle) => Promise, + ): Promise => work({ archive: async () => {}, restore: async () => undefined }), + whenResumeSettled: async () => { + if (options.resumeError !== undefined) throw options.resumeError; + }, + get: () => undefined, + }, + ], + [ISessionIndex, { get: async () => options.indexSummary ?? summary }], + [IBootstrapService, { scope: () => 'sessions' }], + [ + IAtomicDocumentStore, + { + get: (scope: string, key: string) => options.storeGet(scope, key), + set: async (scope: string, key: string, value: unknown) => { + options.onStoreSet?.(scope, key, value); + }, + delete: async (scope: string, key: string) => { + options.onStoreDelete?.(scope, key); + }, + }, + ], + [ + ISessionIndexMirror, + { record: (recorded: SessionSummary) => options.onMirrorRecord?.(recorded) }, + ], + [IEventService, { publish: () => {} }], + ]); +} + +describe('setSessionArchivedBatch', () => { + it('maps a metadata read failure to a per-item internal error, not not_found', async () => { + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ + storeGet: async () => { + throw new Error('disk on fire'); + }, + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([{ id: 's1', ok: false, reason: 'error', message: 'disk on fire' }]); + }); + + it('maps a missing metadata document to not_found', async () => { + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ storeGet: async () => undefined }), + ['s1'], + true, + ); + expect(outcomes).toEqual([ + { id: 's1', ok: false, reason: 'not_found', message: 'session s1 does not exist' }, + ]); + }); + + it('mirrors the persisted metadata, not a stale index summary', async () => { + const recorded: SessionSummary[] = []; + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ + indexSummary: { ...summary, title: 'stale', lastPrompt: 'stale-p', updatedAt: 1 }, + storeGet: async () => ({ + id: 's1', + title: 'fresh', + lastPrompt: 'fresh-p', + createdAt: 1, + updatedAt: 9, + archived: false, + }), + onMirrorRecord: (r) => recorded.push(r), + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([{ id: 's1', ok: true }]); + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ + workspaceId: 'wd', + title: 'fresh', + lastPrompt: 'fresh-p', + updatedAt: 9, + archived: true, + }); + expect(typeof recorded[0]?.archivedAt).toBe('number'); + }); + + it('normalizes legacy v1 metadata before persisting and mirroring', async () => { + const recorded: SessionSummary[] = []; + const written: unknown[] = []; + const legacy = { + workDir: '/workspace', + customTitle: 'legacy title', + createdAt: '2026-07-21T19:40:00.000Z', + updatedAt: '2026-07-22T02:00:00.000Z', + archived: false, + } as unknown as SessionMeta; + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ + storeGet: async () => legacy, + onMirrorRecord: (r) => recorded.push(r), + onStoreSet: (_scope, _key, value) => written.push(value), + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([{ id: 's1', ok: true }]); + + const rec = recorded[0]; + expect(rec?.title).toBe('legacy title'); + expect(rec?.updatedAt).toBe(Date.parse('2026-07-22T02:00:00.000Z')); + + const persisted = written[0] as Record; + expect(persisted['version']).toBe(2); + expect(typeof persisted['updatedAt']).toBe('number'); + expect(persisted['customTitle']).toBeUndefined(); + expect(persisted['isCustomTitle']).toBe(true); + }); + + it('fails the item when a concurrent resume failed instead of cold-classifying', async () => { + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ + storeGet: async () => { + throw new Error('unreachable — the settle throws first'); + }, + resumeError: new Error('resume boom'), + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([ + { id: 's1', ok: false, reason: 'error', message: 'resume boom' }, + ]); + }); + + it('reads and migrates the legacy session-meta location before answering not_found', async () => { + const written: Array<{ scope: string; value: unknown }> = []; + const deleted: string[] = []; + const meta: SessionMeta = { id: 's1', createdAt: 1, updatedAt: 2, archived: false }; + const outcomes = await setSessionArchivedBatch( + coldPathAccessor({ + storeGet: async (scope) => (scope.endsWith('/session-meta') ? meta : undefined), + onStoreSet: (scope, _key, value) => written.push({ scope, value }), + onStoreDelete: (scope) => deleted.push(scope), + }), + ['s1'], + true, + ); + expect(outcomes).toEqual([{ id: 's1', ok: true }]); + expect(written).toHaveLength(1); + expect(written[0]?.scope.endsWith('/session-meta')).toBe(false); + expect(deleted).toHaveLength(1); + expect(deleted[0]?.endsWith('/session-meta')).toBe(true); + }); +}); diff --git a/packages/kap-server/src/routes/v2/sessions.ts b/packages/kap-server/src/routes/v2/sessions.ts index 131f488ba..8a0c6c729 100644 --- a/packages/kap-server/src/routes/v2/sessions.ts +++ b/packages/kap-server/src/routes/v2/sessions.ts @@ -1,9 +1,12 @@ + import { createHash } from 'node:crypto'; import { ISessionIndex, + ISessionIndexMirror, IWorkspaceAliases, IWorkspaceService, + setSessionArchivedBatch, type Scope, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; @@ -24,6 +27,14 @@ interface V2SessionsRouteHost { reply: { send(payload: unknown): unknown }, ) => Promise | void, ): unknown; + post( + path: string, + options: { preHandler: unknown[]; schema?: Record }, + handler: ( + req: { id: string; body: unknown; params: unknown; headers: Record }, + reply: { send(payload: unknown): unknown }, + ) => Promise | void, + ): unknown; } export const v2ActivityStatusSchema = z.enum([ @@ -56,18 +67,48 @@ function includeDomains(include: string | undefined): string[] { .filter((value) => value.length > 0); } +const KNOWN_FIELDS = new Set(['id', 'archived']); +const IDS_PROJECTION_PAGE_SIZE_MAX = 10000; +const FULL_PAGE_SIZE_MAX = 100; + +function parseFields(raw: string | undefined): string[] { + return [ + ...new Set( + (raw ?? '') + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0), + ), + ]; +} + +function isIdsProjection(fields: readonly string[]): boolean { + return fields.length === 2 && fields.every((field) => KNOWN_FIELDS.has(field)); +} + const v2SessionsListQuerySchema = z .object({ 'workspace.id': repeatedParam(z.string().min(1)), 'activity.status': repeatedParam(v2ActivityStatusSchema), 'meta.updated_after': z.coerce.number().int().nonnegative().optional(), + 'meta.updated_before': z.coerce.number().int().nonnegative().optional(), 'meta.archived': z.enum(['true', 'false', 'all']).optional(), sort: v2SortSchema.optional(), include: z.string().optional(), - page_size: z.coerce.number().int().min(1).max(100).optional(), + fields: z.string().optional(), + page_size: z.coerce.number().int().min(1).max(IDS_PROJECTION_PAGE_SIZE_MAX).optional(), + page: z.coerce.number().int().min(1).optional(), page_token: z.string().min(1).optional(), }) .superRefine((value, ctx) => { + if (value.page !== undefined && value.page_token !== undefined) { + ctx.addIssue({ + code: 'custom', + message: 'page and page_token are mutually exclusive', + path: ['page'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } for (const domain of includeDomains(value.include)) { if (!KNOWN_INCLUDE_DOMAINS.has(domain)) { ctx.addIssue({ @@ -78,6 +119,45 @@ const v2SessionsListQuerySchema = z }); } } + const fields = parseFields(value.fields); + for (const field of fields) { + if (!KNOWN_FIELDS.has(field)) { + ctx.addIssue({ + code: 'custom', + message: `unknown field '${field}'`, + path: ['fields'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + } + const projection = fields.length > 0 && fields.every((field) => KNOWN_FIELDS.has(field)); + if (projection && !isIdsProjection(fields)) { + ctx.addIssue({ + code: 'custom', + message: "unsupported fields projection; the only supported value is 'id,archived'", + path: ['fields'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + if (projection && includeDomains(value.include).includes('git')) { + ctx.addIssue({ + code: 'custom', + message: 'include=git is not available with the ids projection', + path: ['include'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + const pageSizeMax = projection ? IDS_PROJECTION_PAGE_SIZE_MAX : FULL_PAGE_SIZE_MAX; + if (value.page_size !== undefined && value.page_size > pageSizeMax) { + ctx.addIssue({ + code: 'custom', + message: projection + ? `page_size must be at most ${IDS_PROJECTION_PAGE_SIZE_MAX}` + : `page_size must be at most ${FULL_PAGE_SIZE_MAX} without the ids projection`, + path: ['page_size'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } }); function asArray(value: T | T[] | undefined): T[] | undefined { @@ -89,10 +169,12 @@ interface NormalizedQuery { readonly workspaceFilter?: readonly string[]; readonly statuses?: readonly V2ActivityStatus[]; readonly updatedAfter?: number; + readonly updatedBefore?: number; readonly archived: 'true' | 'false' | 'all'; readonly sort: V2Sort; readonly includeGit: boolean; readonly pageSize: number; + readonly projection: boolean; } const v2GitDomainSchema = z.object({ @@ -121,16 +203,53 @@ const v2SessionSchema = z.object({ git: v2GitDomainSchema.optional(), }); +const v2SessionIdProjectionSchema = z.object({ + id: z.string(), + archived: z.boolean(), +}); + const v2SessionPageSchema = z.object({ - items: z.array(v2SessionSchema), + items: z.array(z.union([v2SessionSchema, v2SessionIdProjectionSchema])), + total: z.number().int(), has_more: z.boolean(), next_page_token: z.string().nullable(), }); const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() })); + +const BATCH_IDS_MAX = 5000; + +const v2SessionsBatchBodySchema = z + .object({ ids: z.array(z.string().min(1)).min(1) }) + .superRefine((value, ctx) => { + if (new Set(value.ids).size > BATCH_IDS_MAX) { + ctx.addIssue({ + code: 'custom', + message: `ids must contain at most ${BATCH_IDS_MAX} unique entries`, + path: ['ids'], + params: { code: ErrorCode.VALIDATION_FAILED }, + }); + } + }); + +const v2SessionsBatchResultSchema = z.object({ + results: z.array( + z.object({ + id: z.string(), + ok: z.boolean(), + error: z.object({ code: z.number().int(), message: z.string() }).optional(), + }), + ), + succeeded: z.number().int(), + failed: z.number().int(), +}); + +type V2BatchItemResult = z.infer['results'][number]; + type V2GitDomain = z.infer; type V2SessionWire = z.infer; +type V2SessionIdProjection = z.infer; class PageTokenMismatchError extends Error {} @@ -177,10 +296,12 @@ function queryFingerprint(query: NormalizedQuery): string { query.workspaceFilter === undefined ? null : [...query.workspaceFilter].toSorted(), query.statuses === undefined ? null : [...query.statuses].toSorted(), query.updatedAfter ?? null, + query.updatedBefore ?? null, query.archived, query.sort, query.includeGit, query.pageSize, + query.projection, ]; return createHash('sha256').update(JSON.stringify(canonical)).digest('base64url').slice(0, 16); } @@ -271,6 +392,34 @@ class GitDomainResolver { } } +async function runBatchArchive( + core: Scope, + action: 'archive' | 'restore', + rawIds: readonly string[], + requestId: string, + reply: { send(payload: unknown): unknown }, +): Promise { + const archived = action === 'archive'; + const ids = [...new Set(rawIds)]; + const outcomes = await setSessionArchivedBatch(core.accessor, ids, archived); + const results: V2BatchItemResult[] = outcomes.map((outcome) => + outcome.ok + ? { id: outcome.id, ok: true } + : { + id: outcome.id, + ok: false, + error: + outcome.reason === 'not_found' + ? { code: ErrorCode.SESSION_NOT_FOUND, message: outcome.message } + : { code: ErrorCode.INTERNAL_ERROR, message: outcome.message }, + }, + ); + await core.accessor.get(ISessionIndexMirror).drain(); + const succeeded = results.filter((result) => result.ok).length; + reply.send( + okEnvelope({ results, succeeded, failed: results.length - succeeded }, requestId), + ); +} export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): void { const gitResolver = new GitDomainResolver(core); @@ -285,7 +434,7 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): [ErrorCode.PAGE_TOKEN_MISMATCH]: {}, }, description: - 'List sessions with domain-grouped metadata (workspace / meta / activity; git via include=git). Opaque-cursor pagination: page_token binds the first page’s query conditions.', + "List sessions with domain-grouped metadata (workspace / meta / activity; git via include=git). Paginate with the opaque page_token (binds the first page’s query conditions) or with the stateless 1-based page parameter; every page carries total. fields=id,archived trims each item to the lightweight ids projection (select-all-matching flows; page_size ceiling relaxed to 10000).", tags: ['v2-sessions'], }, async (req, reply) => { @@ -295,10 +444,12 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): workspaceFilter: asArray(raw['workspace.id']), statuses: asArray(raw['activity.status']), updatedAfter: raw['meta.updated_after'], + updatedBefore: raw['meta.updated_before'], archived: raw['meta.archived'] ?? 'false', sort: raw.sort ?? 'meta.updated_at_desc', includeGit: includeDomains(raw.include).includes('git'), pageSize: raw.page_size ?? DEFAULT_PAGE_SIZE, + projection: parseFields(raw.fields).length > 0, }; const fingerprint = queryFingerprint(query); @@ -344,6 +495,9 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): if (query.updatedAfter !== undefined && summary.updatedAt < query.updatedAfter) { return false; } + if (query.updatedBefore !== undefined && summary.updatedAt > query.updatedBefore) { + return false; + } if ( query.statuses !== undefined && !query.statuses.includes(mapActivityStatus(factsOf(summary.id), summary.lastTurnReason)) @@ -357,7 +511,9 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): const sorted = filtered.toSorted(comparator); let start = 0; - if (cursor !== undefined) { + if (raw.page !== undefined) { + start = (raw.page - 1) * query.pageSize; + } else if (cursor !== undefined) { const [cursorKey, cursorId] = cursor; const cursorItem = { id: cursorId, @@ -372,10 +528,28 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): const hasMore = start + query.pageSize < sorted.length; const lastServed = window.at(-1); const nextPageToken = - hasMore && lastServed !== undefined + raw.page === undefined && hasMore && lastServed !== undefined ? encodePageToken(fingerprint, sortKeyOf(query.sort)(lastServed), lastServed.id) : null; + if (query.projection) { + const projected: V2SessionIdProjection[] = window.map((summary) => ({ + id: summary.id, + archived: summary.archived, + })); + reply.send( + okEnvelope( + { + items: projected, + total: sorted.length, + has_more: hasMore, + next_page_token: nextPageToken, + }, + req.id, + ), + ); + return; + } const roots = new Map( (await core.accessor.get(IWorkspaceService).list()).map( (workspace) => [workspace.id, workspace.root] as const, @@ -415,7 +589,12 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): }; }); - reply.send(okEnvelope({ items, has_more: hasMore, next_page_token: nextPageToken }, req.id)); + reply.send( + okEnvelope( + { items, total: sorted.length, has_more: hasMore, next_page_token: nextPageToken }, + req.id, + ), + ); }, ); @@ -424,4 +603,28 @@ export function registerV2SessionsRoutes(app: V2SessionsRouteHost, core: Scope): listRoute.options, listRoute.handler as Parameters[2], ); + + for (const action of ['archive', 'restore'] as const) { + const batchRoute = defineRoute( + { + method: 'POST', + path: `/sessions::${action}`, + body: v2SessionsBatchBodySchema, + success: { data: v2SessionsBatchResultSchema }, + errors: { + [ErrorCode.VALIDATION_FAILED]: { detailsSchema }, + }, + description: `Batch-${action} sessions by id ({ ids }, ≤5000 unique). Per-item results — a missing session folds into its own item; cold sessions are patched without materialization.`, + tags: ['v2-sessions'], + }, + async (req, reply) => { + await runBatchArchive(core, action, req.body.ids, req.id, reply); + }, + ); + app.post( + batchRoute.path, + batchRoute.options, + batchRoute.handler as Parameters[2], + ); + } } diff --git a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap index 06ef8abb2..e6126f651 100644 --- a/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap +++ b/packages/kap-server/test/__snapshots__/apiSurface.snapshot.test.ts.snap @@ -468,6 +468,14 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e "POST", "/api/v1/workspaces/{workspace_id}/untrust", ], + [ + "POST", + "/api/v2/sessions:archive", + ], + [ + "POST", + "/api/v2/sessions:restore", + ], [ "PUT", "/api/v1/providers/{provider_id}", diff --git a/packages/kap-server/test/v2Sessions.test.ts b/packages/kap-server/test/v2Sessions.test.ts index 73a8115a2..5349efae3 100644 --- a/packages/kap-server/test/v2Sessions.test.ts +++ b/packages/kap-server/test/v2Sessions.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -6,6 +6,12 @@ import { Error2, ErrorCodes, ISessionIndex, + IEventService, + closeSessionById, + getLiveSessionById, + resumeSessionById, + sessionDirOf, + type Event2, type SessionSummary, } from '@moonshot-ai/agent-core-v2'; import { @@ -13,7 +19,7 @@ import { type FsPullRequest, IGitService, } from '@moonshot-ai/agent-core-v2/app/git/git'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type RunningServer, startServer } from '../src/start'; import { mapActivityStatus } from '../src/routes/v2/sessions'; @@ -39,6 +45,7 @@ interface SessionWireV2 { interface PageWireV2 { items: SessionWireV2[]; + total: number; has_more: boolean; next_page_token: string | null; } @@ -256,6 +263,32 @@ describe('server /api/v2/sessions', () => { expect(page.items.map((item) => item.id)).toEqual(['s1', 's2']); }); + it('filters by meta.updated_before (inclusive), combined into a range', async () => { + const before = await getData('?meta.updated_before=4000'); + expect(before.items.map((item) => item.id)).toEqual(['s2', 's3']); + expect(before.total).toBe(2); + + const range = await getData('?meta.updated_after=3000&meta.updated_before=4000'); + expect(range.items.map((item) => item.id)).toEqual(['s2', 's3']); + + const bogus = await getError('?meta.updated_before=-1'); + expect(bogus.code).toBe(40001); + }); + + it('binds meta.updated_before into the page_token fingerprint', async () => { + const page1 = await getData('?page_size=1&meta.updated_before=4500'); + expect(page1.items.map((item) => item.id)).toEqual(['s2']); + expect(page1.has_more).toBe(true); + + const page2 = await getData( + `?page_size=1&meta.updated_before=4500&page_token=${page1.next_page_token}`, + ); + expect(page2.items.map((item) => item.id)).toEqual(['s3']); + + const drifted = await getError(`?page_size=1&page_token=${page1.next_page_token}`); + expect(drifted.code).toBe(40922); + }); + it('filters by meta.archived (default false / true / all)', async () => { const only = await getData('?meta.archived=true'); expect(only.items.map((item) => item.id)).toEqual(['s4']); @@ -285,6 +318,63 @@ describe('server /api/v2/sessions', () => { } }); + it('projects items to {id, archived} with fields=id,archived (relaxed page_size ceiling)', async () => { + const page = await getData('?fields=id,archived&page_size=10000'); + expect(page.total).toBe(3); + expect(page.items).toEqual([ + { id: 's1', archived: false }, + { id: 's2', archived: false }, + { id: 's3', archived: false }, + ]); + + const all = await getData('?fields=id,archived&meta.archived=all&sort=meta.updated_at_asc'); + expect(all.items).toEqual([ + { id: 's4', archived: true }, + { id: 's3', archived: false }, + { id: 's2', archived: false }, + { id: 's1', archived: false }, + ]); + + const full = await getError('?page_size=101'); + expect(full.code).toBe(40001); + const tooBig = await getError('?fields=id,archived&page_size=10001'); + expect(tooBig.code).toBe(40001); + }); + + it('rejects malformed fields projections (40001)', async () => { + expect((await getError('?fields=id,foo')).code).toBe(40001); + expect((await getError('?fields=id')).code).toBe(40001); + expect((await getError('?fields=archived')).code).toBe(40001); + expect((await getError('?fields=id,archived&include=git')).code).toBe(40001); + }); + + it('paginates the ids projection with an opaque cursor', async () => { + const page1 = await getData('?fields=id,archived&page_size=2'); + expect(page1.items).toEqual([ + { id: 's1', archived: false }, + { id: 's2', archived: false }, + ]); + expect(page1.has_more).toBe(true); + + const page2 = await getData( + `?fields=id,archived&page_size=2&page_token=${page1.next_page_token}`, + ); + expect(page2.items).toEqual([{ id: 's3', archived: false }]); + expect(page2.has_more).toBe(false); + }); + + it('binds the projection into the page_token fingerprint', async () => { + const full = await getData('?page_size=2'); + expect( + (await getError(`?fields=id,archived&page_size=2&page_token=${full.next_page_token}`)).code, + ).toBe(40922); + + const projected = await getData('?fields=id,archived&page_size=2'); + expect((await getError(`?page_size=2&page_token=${projected.next_page_token}`)).code).toBe( + 40922, + ); + }); + it('paginates with an opaque cursor across pages', async () => { const page1 = await getData('?page_size=2'); expect(page1.items.map((item) => item.id)).toEqual(['s1', 's2']); @@ -330,6 +420,53 @@ describe('server /api/v2/sessions', () => { expect(resorted.code).toBe(40922); }); + it('carries total (filtered set size) in every page mode', async () => { + const all = await getData(); + expect(all.total).toBe(3); + + const filtered = await getData(`?workspace.id=${WS_A}`); + expect(filtered.total).toBe(2); + + const page1 = await getData('?page_size=2'); + expect(page1.total).toBe(3); + const page2 = await getData(`?page_size=2&page_token=${page1.next_page_token}`); + expect(page2.total).toBe(3); + }); + + it('paginates by 1-based page without minting tokens', async () => { + const page1 = await getData('?page=1&page_size=2'); + expect(page1.items.map((item) => item.id)).toEqual(['s1', 's2']); + expect(page1.total).toBe(3); + expect(page1.has_more).toBe(true); + expect(page1.next_page_token).toBeNull(); + + const page2 = await getData('?page=2&page_size=2'); + expect(page2.items.map((item) => item.id)).toEqual(['s3']); + expect(page2.total).toBe(3); + expect(page2.has_more).toBe(false); + + const beyond = await getData('?page=7&page_size=2'); + expect(beyond.items).toEqual([]); + expect(beyond.total).toBe(3); + expect(beyond.has_more).toBe(false); + }); + + it('honors filters and sort in page mode', async () => { + const page = await getData(`?workspace.id=${WS_A}&sort=meta.updated_at_asc&page=2&page_size=1`); + expect(page.items.map((item) => item.id)).toEqual(['s1']); + expect(page.total).toBe(2); + expect(page.has_more).toBe(false); + }); + + it('rejects page combined with page_token (40001), and page=0', async () => { + const first = await getData('?page_size=2'); + const both = await getError(`?page=2&page_token=${first.next_page_token}`); + expect(both.code).toBe(40001); + + const zero = await getError('?page=0'); + expect(zero.code).toBe(40001); + }); + it('rejects a corrupted page_token (40922)', async () => { const body = await getError('?page_token=!!!not-a-token'); expect(body.code).toBe(40922); @@ -388,6 +525,275 @@ describe('server /api/v2/sessions', () => { }); }); +describe('server /api/v2/sessions batch archive/restore', () => { + interface BatchItemWire { + id: string; + ok: boolean; + error?: { code: number; message: string }; + } + + interface BatchWire { + results: BatchItemWire[]; + succeeded: number; + failed: number; + } + + interface BatchEnvelopeWire { + code: number; + msg: string; + data: BatchWire | null; + request_id: string; + details?: { path: string; message: string }[]; + } + + let server: RunningServer | undefined; + let home: string | undefined; + let base: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-sessions-batch-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + base = `http://127.0.0.1:${server.port}`; + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await new Promise((resolve) => setTimeout(resolve, 25)); + await rm(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as never); + home = undefined; + } + }); + + function core(): RunningServer['core']['accessor'] { + return (server as RunningServer).core.accessor; + } + + function collectEvents(): { events: Event2[]; dispose(): void } { + const events: Event2[] = []; + const sub = core().get(IEventService).subscribe((event) => events.push(event)); + return { + events, + dispose: () => { + sub.dispose(); + }, + }; + } + + async function createSession(): Promise<{ id: string; workspace_id: string }> { + const res = await authedFetch(server as RunningServer, base, '/api/v1/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ metadata: { cwd: home } }), + }); + const body = (await res.json()) as { + code: number; + data: { id: string; workspace_id: string }; + }; + expect(body.code).toBe(0); + return body.data; + } + + async function postBatch(path: string, body?: unknown): Promise { + const res = await authedFetch(server as RunningServer, base, path, { + method: 'POST', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + expect(res.status).toBe(200); + return (await res.json()) as BatchEnvelopeWire; + } + + async function readStateJson(workspaceId: string, id: string): Promise> { + const dir = sessionDirOf(home as string, `sessions/${workspaceId}`, id); + return JSON.parse(await readFile(join(dir, 'state.json'), 'utf-8')) as Record; + } + + async function indexArchived(id: string): Promise { + return (await core().get(ISessionIndex).get(id))?.archived; + } + + async function listedIds(query = ''): Promise { + const res = await authedFetch(server as RunningServer, base, `/api/v2/sessions${query}`); + const body = (await res.json()) as { code: number; data: { items: { id: string }[] } }; + expect(body.code).toBe(0); + return body.data.items.map((item) => item.id); + } + + it('archives a cold session without materializing it or touching a workspace handler', async () => { + const created = await createSession(); + await closeSessionById(core(), created.id); + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + + const { events, dispose } = collectEvents(); + const before = await readStateJson(created.workspace_id, created.id); + + const body = await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + expect(body.code).toBe(0); + expect(body.data).toMatchObject({ + succeeded: 1, + failed: 0, + results: [{ id: created.id, ok: true }], + }); + + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + + const after = await readStateJson(created.workspace_id, created.id); + expect(after['archived']).toBe(true); + expect(typeof after['archivedAt']).toBe('number'); + expect(after['updatedAt']).toBe(before['updatedAt']); + expect(after['createdAt']).toBe(before['createdAt']); + expect(after['agents']).toEqual(before['agents']); + + expect(await indexArchived(created.id)).toBe(true); + expect(await listedIds('?meta.archived=true')).toEqual([created.id]); + expect(await listedIds()).toEqual([]); + + expect( + events + .filter((event) => event.type === 'event.session.archived') + .map((event) => ({ + type: event.type, + payload: (event as { readonly payload?: unknown }).payload, + })), + ).toEqual([{ type: 'event.session.archived', payload: { sessionId: created.id } }]); + dispose(); + }); + + it('archives a live session through the full lifecycle chain', async () => { + const created = await createSession(); + expect(getLiveSessionById(core(), created.id)).toBeDefined(); + const { events, dispose } = collectEvents(); + + const body = await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + expect(body.code).toBe(0); + expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); + + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + expect( + events.some( + (event) => + event.type === 'event.session.archived' && + ((event as { readonly payload?: unknown }).payload as { sessionId: string }) + .sessionId === created.id, + ), + ).toBe(true); + expect(await indexArchived(created.id)).toBe(true); + dispose(); + }); + + it('settles an in-flight resume before classifying (no cold-write race)', async () => { + const created = await createSession(); + await closeSessionById(core(), created.id); + + const resumePromise = resumeSessionById(core(), created.id); + const batchPromise = postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + + const handle = await resumePromise; + expect(handle).toBeDefined(); + const body = await batchPromise; + expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); + + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + expect(await indexArchived(created.id)).toBe(true); + expect((await readStateJson(created.workspace_id, created.id))['archived']).toBe(true); + }); + + it('reports per-item results in input order for a live/cold/missing mixed batch', async () => { + const live = await createSession(); + const cold = await createSession(); + await closeSessionById(core(), cold.id); + + const body = await postBatch('/api/v2/sessions:archive', { + ids: [live.id, cold.id, 'sess_missing'], + }); + expect(body.code).toBe(0); + expect(body.data?.results).toEqual([ + { id: live.id, ok: true }, + { id: cold.id, ok: true }, + { + id: 'sess_missing', + ok: false, + error: { code: 40401, message: 'session sess_missing does not exist' }, + }, + ]); + expect(body.data?.succeeded).toBe(2); + expect(body.data?.failed).toBe(1); + expect(await indexArchived(live.id)).toBe(true); + expect(await indexArchived(cold.id)).toBe(true); + }); + + it('restores a cold session without materializing it and publishes no archived event', async () => { + const created = await createSession(); + await closeSessionById(core(), created.id); + await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + expect(await indexArchived(created.id)).toBe(true); + + const { events, dispose } = collectEvents(); + const before = await readStateJson(created.workspace_id, created.id); + + const body = await postBatch('/api/v2/sessions:restore', { ids: [created.id] }); + expect(body.code).toBe(0); + expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); + + expect(getLiveSessionById(core(), created.id)).toBeUndefined(); + + const after = await readStateJson(created.workspace_id, created.id); + expect(after['archived']).toBe(false); + expect('archivedAt' in after).toBe(false); + expect(after['updatedAt']).toBe(before['updatedAt']); + + expect(await indexArchived(created.id)).toBe(false); + expect(await listedIds()).toEqual([created.id]); + expect(events.filter((event) => event.type === 'event.session.archived')).toEqual([]); + dispose(); + }); + + it('restores a live session through the lifecycle chain and keeps it live', async () => { + const created = await createSession(); + await postBatch('/api/v2/sessions:archive', { ids: [created.id] }); + expect(await resumeSessionById(core(), created.id)).toBeDefined(); + + const body = await postBatch('/api/v2/sessions:restore', { ids: [created.id] }); + expect(body.code).toBe(0); + expect(body.data?.results).toEqual([{ id: created.id, ok: true }]); + + expect(getLiveSessionById(core(), created.id)).toBeDefined(); + expect(await indexArchived(created.id)).toBe(false); + }); + + it('validates the batch body: empty, missing, over the unique cap, duplicates', async () => { + for (const body of [{ ids: [] }, {}]) { + const rejected = await postBatch('/api/v2/sessions:archive', body); + expect(rejected.code).toBe(40001); + expect(rejected.data).toBeNull(); + } + + const tooMany = await postBatch('/api/v2/sessions:archive', { + ids: Array.from({ length: 5001 }, (_, i) => `sess_${i}`), + }); + expect(tooMany.code).toBe(40001); + + const deduped = await postBatch('/api/v2/sessions:archive', { + ids: Array.from({ length: 5001 }, () => 'sess_dup'), + }); + expect(deduped.code).toBe(0); + expect(deduped.data?.results).toHaveLength(1); + expect(deduped.data?.results[0]?.ok).toBe(false); + expect(deduped.data?.results[0]?.error?.code).toBe(40401); + }); +}); + describe('mapActivityStatus', () => { it('maps a cold persisted failure to failed, live outcomes still win', () => { const coldIdle = { busy: false, mainTurnActive: false, pendingInteraction: 'none' as const, live: false as const };