From 53ceab53b82fac05251653d8bd6de2191c9c75c9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:40:03 +0800 Subject: [PATCH] feat(apiproxy): workspace.archiveSession RPC and archived-sessions frame workspace.archiveSession answers the full updated archive set; workspace.list carries the set as the reconnect baseline; the host stream pushes host/archived-sessions-changed full snapshots from the domain/changed global-put branch (same posture as workspace-changed). Unknown sessions map to the existing session-not-found code. --- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 36 ++++++++++++++++- .../host/apiproxy/src/api/events.schema.ts | 1 + packages/host/apiproxy/src/api/events.ts | 5 ++- packages/host/apiproxy/src/api/rpc-map.ts | 1 + .../host/apiproxy/src/api/workspace.schema.ts | 11 +++++ packages/host/apiproxy/src/api/workspace.ts | 20 +++++++++- packages/host/apiproxy/src/fetch/client.ts | 4 ++ packages/host/apiproxy/src/fetch/handler.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 40 +++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 7 +++- .../host/apiproxy/tests/fetch-carrier.spec.ts | 5 ++- .../host/apiproxy/tests/rpc-schemas.spec.ts | 12 +++++- 15 files changed, 139 insertions(+), 13 deletions(-) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 573790009f..1bd0f19d0b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: b12c1179a7b64b4b67efa01598e19bffc9f4198c -README.zh.md: 740c01bf46a0df56470fd2b4655e3c21317199bb +README.md: 8f08d90f8a91afc2ff022761d2d83de055df18a0 +README.zh.md: febeba0e601d75cbc69490d29135c206189efe29 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index b12c1179a7..8f08d90f8a 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -22,7 +22,7 @@ Session model routing is a session-domain contract. `session.models` returns the Pending queued input is a live control-plane contract, not session history. The gateway mirrors queued `InboxItem` occurrences from `agent/inbox/*` and broadcasts authoritative `session/queue` snapshots on every queued change and reconnect; pending steering stays outside this Web projection. `session.updateQueue` addresses one `InboxItemId`: edit replaces pending content and remove discards it. A driver claim wins races by retiring the address before admission; a later operation returns `queue-item-not-found`. The operation queries only an attached Agent and never resumes a cold session because process-local inbox identities do not survive restart or disposal. The client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 740c01bf46..febeba0e60 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -22,7 +22,7 @@ 待处理的 queued 输入属于实时控制平面契约,而非会话历史。网关镜像来自 `agent/inbox/*` 的 queued `InboxItem` 入队项,并在每次 queued 变更和重连时广播权威的 `session/queue` 快照;待处理 steering(中途引导)不进入此 Web 投影。`session.updateQueue` 通过 `InboxItemId` 寻址单个项:编辑会替换待处理内容,移除会将其丢弃。驱动器在接纳前退役寻址标识,因此认领会赢得竞态;之后的操作返回 `queue-item-not-found`。该操作只查询当前已挂载的 Agent,绝不恢复冷会话,因为进程本地 inbox 标识无法在重启或资源释放后存活。客户端绝不根据轮次或状态事件推断项已退役。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前 surface user、assistant 和 steering(中途引导)匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 205c9912f5..b6365a4a52 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -21,7 +21,7 @@ import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-se import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, - WorkspaceMoveInvalidError, WorkspaceNameConflictError, + WorkspaceMoveInvalidError, WorkspaceNameConflictError, WorkspaceUnknownSessionError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' @@ -1582,7 +1582,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro workspace: { list(request) { - return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) })) + return Promise.resolve(ok(request, { + items: ctx.workspace.list().map(workspaceView), + archivedSessionIds: [...ctx.workspace.archivedSessionIds], + })) }, // Exactly one of path/name arrives (schema refine). Existing-folder @@ -1698,6 +1701,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } return ok(request, { workspace: workspaceView(workspace) }) }, + + async archiveSession(request) { + const { sessionId } = request.payload + try { + await ctx.workspace.archiveSession(sessionId) + } catch (error: unknown) { + // Only the registry's unknown-session rejection is the business + // code; storage/durability failures propagate as internal errors. + if (!(error instanceof WorkspaceUnknownSessionError)) throw error + return err(request, { + code: 'session-not-found', + message: error.message, + details: { sessionId }, + }) + } + return ok(request, { archivedSessionIds: [...ctx.workspace.archivedSessionIds] }) + }, }, host: { @@ -2109,6 +2129,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const committedWorkspaceIds = new Set( ctx.workspace.list().map(workspace => String(workspace.id)), ) + // Frame-dedup baseline, same posture as committedWorkspaceIds: the + // stream opens against the current set; workspace.list re-baselines + // reconnecting clients, so only later changes need frames. + let archivedSessionIds = ctx.workspace.archivedSessionIds const disposers = [ ctx.on('session/created', (session: Session) => { queue.push(frame({ @@ -2145,6 +2169,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro committedWorkspaceIds.add(workspaceId) queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) })) } + if (state.archivedSessionIds.length !== archivedSessionIds.length + || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) { + archivedSessionIds = state.archivedSessionIds + queue.push(frame({ + type: 'host/archived-sessions-changed', + archivedSessionIds: [...state.archivedSessionIds], + })) + } return } if (change.table !== 'workspaces') return diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 186c189879..ea4b6c892f 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -71,6 +71,7 @@ export const hostFrameSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('host/agent-error'), sessionId: sessionIdSchema, message: z.string() }), z.object({ type: z.literal('host/workspace-changed'), workspace: workspaceViewSchema }), z.object({ type: z.literal('host/workspace-removed'), workspaceId: workspaceIdSchema }), + z.object({ type: z.literal('host/archived-sessions-changed'), archivedSessionIds: z.array(sessionIdSchema) }), z.object({ type: z.literal('host/commands-changed') }), z.object({ type: z.literal('host/settings-changed'), ns: z.string() }), z.object({ type: z.literal('host/credentials-changed'), ref: z.string() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index d678f2f911..d8ee3f6bff 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -101,7 +101,9 @@ export type MuxFrame = * workspace mutation (create/attach/order change — the client upserts, while * `workspace.list` provides the reconnect baseline); workspace-removed is the * committed registration-deletion increment and never implies directory or - * session-log deletion. + * session-log deletion; archived-sessions-changed pushes the full registry + * archive set after every durable change (same full-snapshot posture as + * workspace-changed — `workspace.list` re-baselines it on reconnect). */ export type HostFrame = | { type: 'host/session-added'; sessionId: SessionId; blank: boolean; parentSessionId?: SessionId; cwd?: string } @@ -110,6 +112,7 @@ export type HostFrame = | { type: 'host/agent-error'; sessionId: SessionId; message: string } | { type: 'host/workspace-changed'; workspace: WorkspaceView } | { type: 'host/workspace-removed'; workspaceId: WorkspaceView['workspaceId'] } + | { type: 'host/archived-sessions-changed'; archivedSessionIds: SessionId[] } /** * The command registry changed (`commands/change` passthrough). Pure * invalidation signal, no payload: clients refetch `command.list` in the diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index f2f112f7cd..88e2c05575 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -42,6 +42,7 @@ export interface RpcMethodMap { 'workspace.rename': WorkspaceApi['rename'] 'workspace.delete': WorkspaceApi['delete'] 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] + 'workspace.archiveSession': WorkspaceApi['archiveSession'] 'command.list': CommandsApi['list'] 'command.execute': CommandsApi['execute'] 'skill.list': SkillsApi['list'] diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index e16e5339da..20b3038301 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -28,6 +28,7 @@ export const workspaceListRequestSchema = z.object({}) satisfies z.ZodType>> /** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ @@ -80,3 +81,13 @@ export const workspaceInsertSessionBeforeRequestSchema = z.object({ export const workspaceInsertSessionBeforeValueSchema = z.object({ workspace: workspaceViewSchema, }) satisfies z.ZodType>> + +/** workspace.archiveSession request payload. */ +export const workspaceArchiveSessionRequestSchema = z.object({ + sessionId: sessionIdSchema, +}) satisfies z.ZodType>> + +/** workspace.archiveSession response value: the full updated archive set. */ +export const workspaceArchiveSessionValueSchema = z.object({ + archivedSessionIds: z.array(sessionIdSchema), +}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index ff22d845fb..957c566bbd 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -37,8 +37,13 @@ export interface WorkspaceView { /** Workspace-domain unary methods (the map keys workspace.* of RpcMethodMap). */ export interface WorkspaceApi { - /** Lists all workspaces in the registry's durable display order. */ - list(request: RpcRequest<{}>): Promise> + /** + * Lists all workspaces in the registry's durable display order, plus the + * registry-global archive set (the reconnect baseline of + * `host/archived-sessions-changed`). Archived sessions stay in their + * workspace's `sessionIds` account; grouping surfaces hide them. + */ + list(request: RpcRequest<{}>): Promise> /** * Creates (or idempotently resolves) a workspace. Exactly one of `path` / @@ -86,4 +91,15 @@ export interface WorkspaceApi { sessionId: SessionId beforeSessionId?: SessionId }>): Promise> + + /** + * Adds one session to the registry-global archive set: the session + * disappears from every grouping surface but keeps its session log and its + * workspace accounting slot (a future unarchive restores its position). + * Idempotent for an already archived id. A session neither live nor in + * session persistence fails with `session-not-found`. Returns the full + * updated set (same snapshot the changed frame carries). + */ + archiveSession(request: RpcRequest<{ sessionId: SessionId }>): + Promise> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 5261658ee0..7b5c1545de 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -31,6 +31,7 @@ import { sessionUpdateQueueValueSchema, } from '../api/sessions.schema.ts' import { + workspaceArchiveSessionValueSchema, workspaceCreateValueSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeValueSchema, @@ -98,6 +99,7 @@ export interface IApiClient { rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise>> delete(payload: RequestPayload<'workspace.delete'>, signal?: AbortSignal): Promise>> insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise>> + archiveSession(payload: RequestPayload<'workspace.archiveSession'>, signal?: AbortSignal): Promise>> } commands: { list(payload: RequestPayload<'command.list'>, signal?: AbortSignal): Promise>> @@ -163,6 +165,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('workspace.rename', payload, signal), delete: (payload, signal) => this.callUnary('workspace.delete', payload, signal), insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), + archiveSession: (payload, signal) => this.callUnary('workspace.archiveSession', payload, signal), } readonly commands: IApiClient['commands'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 0cf7625a65..6bc060e969 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -33,6 +33,7 @@ import { hostPickDirectoryRequestSchema, } from '../api/host.schema.ts' import { + workspaceArchiveSessionRequestSchema, workspaceCreateRequestSchema, workspaceDeleteRequestSchema, workspaceInsertSessionBeforeRequestSchema, @@ -96,6 +97,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, 'workspace.delete': { schema: workspaceDeleteRequestSchema, invoke: (api, r) => api.workspace.delete(r) }, 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, + 'workspace.archiveSession': { schema: workspaceArchiveSessionRequestSchema, invoke: (api, r) => api.workspace.archiveSession(r) }, 'command.list': { schema: commandListRequestSchema, invoke: (api, r) => api.commands.list(r) }, 'command.execute': { schema: commandExecuteRequestSchema, invoke: (api, r, signal) => api.commands.execute(r, signal) }, 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index f520f8766a..d9211dde6c 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -441,4 +441,44 @@ describe('Host Workspace increments', () => { expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) abort.abort() }) + + it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { + const { api } = await harness() + const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace + const sessionId = SessionId('session-to-archive') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) + expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) + + const abort = new AbortController() + const stream: AsyncIterator> = + api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() + const changed = nextHostFrame(stream) + expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) + .toEqual([sessionId]) + expect(await changed).toMatchObject({ + payload: { type: 'host/archived-sessions-changed', archivedSessionIds: [sessionId] }, + }) + + // Accounting and the session itself are untouched; list re-baselines the set. + const listed = expectOk(await api.workspace.list(request({}))) + expect(listed.archivedSessionIds).toEqual([sessionId]) + expect(listed.items[0]?.sessionIds).toEqual([sessionId]) + expect(expectOk(await api.sessions.list(request({}))).items.map(item => item.sessionId)).toContain(sessionId) + + // The idempotent repeat emits no second frame: the next observed frame is + // the workspace-changed of a later attach, not another archive snapshot. + const after = nextHostFrame(stream) + expect(expectOk(await api.workspace.archiveSession(request({ sessionId }))).archivedSessionIds) + .toEqual([sessionId]) + const otherSession = SessionId('session-after-archive') + expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId: otherSession }))) + expect((await after).payload.type).not.toBe('host/archived-sessions-changed') + + const missing = await api.workspace.archiveSession(request({ sessionId: SessionId('session-ghost') })) + expect(missing.result).toMatchObject({ + ok: false, + error: { code: 'session-not-found', details: { sessionId: 'session-ghost' } }, + }) + abort.abort() + }) }) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 1a667bf4a6..6307dfe8f9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -66,11 +66,12 @@ function scriptedApi(overrides: { ...overrides.host, }, workspace: { - list: r => ok(r, { items: [] }), + list: r => ok(r, { items: [], archivedSessionIds: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), delete: r => ok(r, { deleted: true as const }), insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + archiveSession: r => ok(r, { archivedSessionIds: [r.payload.sessionId] }), }, commands: { list: r => ok(r, { commands: [] }), @@ -360,10 +361,12 @@ describe('workspace domain round trip', () => { it('routes both workspace methods through their handler rows and value schemas', async () => { const c = client(scriptedApi()) const list = await c.workspace.list({}) - expect(list.result).toEqual({ ok: true, value: { items: [] } }) + expect(list.result).toEqual({ ok: true, value: { items: [], archivedSessionIds: [] } }) const created = await c.workspace.create({ path: '/t' }) expect(created.result.ok).toBe(true) if (created.result.ok) expect(created.result.value.created).toBe(true) + const archivedResponse = await c.workspace.archiveSession({ sessionId: 's-arch' as never }) + expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) }) it('rejects a create payload violating the exactly-one refine at the handler', async () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 3a21d6d1ce..ef111afe12 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -122,7 +122,7 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra }, workspace: { async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { items: [] } } } + return { rpcId: request.rpcId, result: { ok: true, value: { items: [], archivedSessionIds: [] } } } }, async create(request) { return { @@ -145,6 +145,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, } }, + async archiveSession(request) { + return { rpcId: request.rpcId, result: { ok: true, value: { archivedSessionIds: [request.payload.sessionId] } } } + }, }, commands: { async list(request) { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index aa9c46d9ae..f6bd093178 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -20,6 +20,7 @@ import { hostListDirectoryRequestSchema, hostListDirectoryValueSchema, } from '../src/api/host.schema.ts' import { + workspaceArchiveSessionRequestSchema, workspaceArchiveSessionValueSchema, workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceDeleteRequestSchema, workspaceDeleteValueSchema, workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, @@ -312,7 +313,16 @@ describe('workspace domain schemas', () => { expect(workspaceViewSchema.parse(view).sessionIds).toEqual(['s1']) expect(() => workspaceViewSchema.parse({ ...view, sessionIds: 's1' })).toThrow() expect(workspaceListRequestSchema.parse({})).toEqual({}) - expect(workspaceListValueSchema.parse({ items: [view] }).items).toHaveLength(1) + expect(workspaceListValueSchema.parse({ items: [view], archivedSessionIds: ['s1'] }).items).toHaveLength(1) + expect(() => workspaceListValueSchema.parse({ items: [view] })).toThrow() + }) + + it('archiveSession request/value carry the id and the full updated set', () => { + expect(workspaceArchiveSessionRequestSchema.parse({ sessionId: 's1' }).sessionId).toBe('s1') + expect(() => workspaceArchiveSessionRequestSchema.parse({})).toThrow() + expect(workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: ['s1', 's2'] }).archivedSessionIds) + .toEqual(['s1', 's2']) + expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) it('create requires exactly one of path/name (both refine arms)', () => {