fix(workspace): unify pin IDs and preserve complete session order
This commit is contained in:
1 parent
f1e06a69ef
commit
7baccf5d85
69 files changed
+956
-543
No files matched your search
@@ -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/api/workspace-controller/README.md
|
||||
README.md: ccf07bfb61ad005d516928ad27145148a62ddf19
|
||||
README.zh.md: b96f49e92696cef2475458f4b51c764063aa32f9
|
||||
README.md: 46556b0105bb5186cf27b56faee4127b358eac9b
|
||||
README.zh.md: 9d210c10486a71772cf6c0a10480b734fa992441
|
||||
@@ -22,9 +22,9 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable `workspace/*` or `directory-picker/*` code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected.
|
||||
The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected.
|
||||
|
||||
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived Session ids, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner.
|
||||
The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定 `workspace/*` 或 `directory-picker/*` 错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。
|
||||
Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。
|
||||
|
||||
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、已归档 Session id、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。
|
||||
Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export type {
|
||||
} from './model.ts'
|
||||
export { WorkspaceController, WorkspaceCreateError } from './service.ts'
|
||||
export type { IWorkspaces, WorkspaceSource } from './service.ts'
|
||||
export type { WorkspaceId, WorkspacePinnedSession, WorkspaceView } from '../types.ts'
|
||||
export type { WorkspaceId, WorkspaceView } from '../types.ts'
|
||||
|
||||
type WorkspaceBaselineFrame = Extract<WorkspaceFollowFrame, { type: 'baseline' }>
|
||||
|
||||
@@ -108,7 +108,7 @@ function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncr
|
||||
accept.replaceArchived(frame.archivedSessionIds)
|
||||
return
|
||||
case 'pinned':
|
||||
accept.replacePinned(frame.pinnedSessions)
|
||||
accept.replacePinned(frame.pinnedSessionIds)
|
||||
return
|
||||
/* v8 ignore next -- the generated Remote codec validates this closed union */
|
||||
default:
|
||||
|
||||
@@ -34,7 +34,7 @@ export interface WorkspaceSnapshot {
|
||||
/** Complete registry-global archive set in Host order. */
|
||||
readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']
|
||||
/** Complete registry-global pin set, most recently pinned first. */
|
||||
readonly pinnedSessions: WorkspacePinValue['pinnedSessions']
|
||||
readonly pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']
|
||||
readonly state: 'idle' | 'loading' | 'error'
|
||||
readonly phase: WorkspaceListPhase
|
||||
readonly error: RemoteFailure | null
|
||||
@@ -53,7 +53,7 @@ export interface WorkspaceFollowSink {
|
||||
/** Replace the complete archived Session set. */
|
||||
replaceArchived(sessionIds: WorkspaceArchiveValue['archivedSessionIds']): void
|
||||
/** Replace the complete pinned Session set. */
|
||||
replacePinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void
|
||||
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +62,7 @@ export interface WorkspaceFollowSink {
|
||||
export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
private items: readonly WorkspaceView[] = []
|
||||
private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = []
|
||||
private pinnedSessions: WorkspacePinValue['pinnedSessions'] = []
|
||||
private pinnedSessionIds: WorkspacePinValue['pinnedSessionIds'] = []
|
||||
private state: WorkspaceSnapshot['state'] = 'loading'
|
||||
private phase: WorkspaceListPhase = 'pending'
|
||||
private error: RemoteFailure | null = null
|
||||
@@ -185,7 +185,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
this.installArchived(result.value.archivedSessionIds)
|
||||
// The Host drops an archived session's pin in the same durable write;
|
||||
// mirror that locally so no frame shows the row both archived and pinned.
|
||||
this.installPinned(this.pinnedSessions.filter(entry => entry.sessionId !== sessionId))
|
||||
this.installPinned(this.pinnedSessionIds.filter(id => id !== sessionId))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -219,7 +219,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
const requestSeq = ++this.pinRequestSeq
|
||||
const result = await this.remote.pinSession({ sessionId })
|
||||
if (result.ok && requestSeq === this.pinRequestSeq) {
|
||||
this.installPinned(result.value.pinnedSessions)
|
||||
this.installPinned(result.value.pinnedSessionIds)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -236,7 +236,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
const requestSeq = ++this.pinRequestSeq
|
||||
const result = await this.remote.unpinSession({ sessionId })
|
||||
if (result.ok && requestSeq === this.pinRequestSeq) {
|
||||
this.installPinned(result.value.pinnedSessions)
|
||||
this.installPinned(result.value.pinnedSessionIds)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
this.pinRequestSeq++
|
||||
this.installViews(baseline.items)
|
||||
this.installArchived(baseline.archivedSessionIds)
|
||||
this.installPinned(baseline.pinnedSessions)
|
||||
this.installPinned(baseline.pinnedSessionIds)
|
||||
this.state = 'idle'
|
||||
this.phase = 'ready'
|
||||
this.error = null
|
||||
@@ -285,11 +285,11 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
|
||||
/**
|
||||
* Replace the pinned Session set from the current follow generation.
|
||||
* @param pinnedSessions - complete Host-confirmed pin set, most recently pinned first.
|
||||
* @param pinnedSessionIds - complete Host-confirmed pin set, most recently pinned first.
|
||||
*/
|
||||
replacePinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void {
|
||||
replacePinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void {
|
||||
this.pinRequestSeq++
|
||||
this.installPinned(pinnedSessions)
|
||||
this.installPinned(pinnedSessionIds)
|
||||
}
|
||||
|
||||
/** Keep the last complete projection visible while a lost carrier reconnects. */
|
||||
@@ -333,7 +333,7 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
return {
|
||||
items: this.items,
|
||||
archivedSessionIds: this.archivedSessionIds,
|
||||
pinnedSessions: this.pinnedSessions,
|
||||
pinnedSessionIds: this.pinnedSessionIds,
|
||||
state: this.state,
|
||||
phase: this.phase,
|
||||
error: this.error,
|
||||
@@ -347,11 +347,10 @@ export class ClientWorkspaceModel implements WorkspaceFollowSink {
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
private installPinned(pinnedSessions: WorkspacePinValue['pinnedSessions']): void {
|
||||
if (pinnedSessions.length === this.pinnedSessions.length
|
||||
&& pinnedSessions.every((entry, index) => entry.sessionId === this.pinnedSessions[index]?.sessionId
|
||||
&& entry.pinnedAt === this.pinnedSessions[index]?.pinnedAt)) return
|
||||
this.pinnedSessions = [...pinnedSessions]
|
||||
private installPinned(pinnedSessionIds: WorkspacePinValue['pinnedSessionIds']): void {
|
||||
if (pinnedSessionIds.length === this.pinnedSessionIds.length
|
||||
&& pinnedSessionIds.every((id, index) => id === this.pinnedSessionIds[index])) return
|
||||
this.pinnedSessionIds = [...pinnedSessionIds]
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export interface IWorkspaces {
|
||||
*/
|
||||
pinSession(sessionId: SessionId): Promise<void>
|
||||
/**
|
||||
* Unpin a Session back to its natural list position.
|
||||
* Remove a Session's pin without changing its saved Session order.
|
||||
* @param sessionId - Session to unpin.
|
||||
*/
|
||||
unpinSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
WorkspaceUnknownSessionError,
|
||||
} from '@deepseek-ai/dsh-workspace'
|
||||
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { pinnedSessionViews, workspaceView } from './feed.ts'
|
||||
import { workspaceView } from './feed.ts'
|
||||
import type {
|
||||
WorkspaceArchiveSessionRequest,
|
||||
WorkspaceArchiveValue,
|
||||
@@ -194,7 +194,7 @@ export class WorkspaceCommands {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) }
|
||||
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,7 +206,7 @@ export class WorkspaceCommands {
|
||||
*/
|
||||
async unpinSession(request: WorkspaceUnpinSessionRequest): Promise<WorkspacePinValue> {
|
||||
await this.ctx.workspaceRegistry.unpinSession(request.sessionId)
|
||||
return { pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions) }
|
||||
return { pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds] }
|
||||
}
|
||||
|
||||
private requireWorkspace(workspaceId: WorkspaceId): Workspace {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
|
||||
import type { PinnedSession, Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
|
||||
import {
|
||||
workspaceDomainState,
|
||||
workspaceRecord,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import type {
|
||||
WorkspaceBaseline,
|
||||
WorkspaceFollowFrame,
|
||||
WorkspacePinnedSession,
|
||||
WorkspaceView,
|
||||
} from './types.ts'
|
||||
|
||||
@@ -44,19 +43,6 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project registry pin entries into their Remote values.
|
||||
* @param entries - authoritative registry pin entries.
|
||||
* @returns detached pin projections for Remote consumers.
|
||||
*/
|
||||
export function pinnedSessionViews(entries: readonly PinnedSession[]): WorkspacePinnedSession[] {
|
||||
return entries.map(entry => ({ sessionId: entry.id, pinnedAt: entry.pinnedAt }))
|
||||
}
|
||||
|
||||
/** One comparable key per pin entry; pinnedAt participates so a re-pin at a new instant publishes. */
|
||||
const pinKeys = (entries: readonly PinnedSession[]): string[] =>
|
||||
entries.map(entry => `${entry.id}@${entry.pinnedAt}`)
|
||||
|
||||
/** Owns Workspace domain observation and all active follow generations. */
|
||||
export class WorkspaceFeed {
|
||||
private readonly followers = new Set<WorkspaceFollower>()
|
||||
@@ -71,7 +57,7 @@ export class WorkspaceFeed {
|
||||
this.knownIds = new Set(baseline.map(workspace => String(workspace.id)))
|
||||
this.order = baseline.map(workspace => String(workspace.id))
|
||||
this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String)
|
||||
this.pinned = pinKeys(ctx.workspaceRegistry.pinnedSessions)
|
||||
this.pinned = ctx.workspaceRegistry.pinnedSessionIds.map(String)
|
||||
ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) })
|
||||
ctx.effect(() => () => {
|
||||
for (const follower of this.followers) follower.close()
|
||||
@@ -87,7 +73,7 @@ export class WorkspaceFeed {
|
||||
return {
|
||||
items: this.ctx.workspaceRegistry.list().map(workspaceView),
|
||||
archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds],
|
||||
pinnedSessions: pinnedSessionViews(this.ctx.workspaceRegistry.pinnedSessions),
|
||||
pinnedSessionIds: [...this.ctx.workspaceRegistry.pinnedSessionIds],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,10 +118,10 @@ export class WorkspaceFeed {
|
||||
this.archived = nextArchived
|
||||
this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] })
|
||||
}
|
||||
const nextPinned = pinKeys(state.pinnedSessions)
|
||||
const nextPinned = state.pinnedSessionIds.map(String)
|
||||
if (!sameStrings(this.pinned, nextPinned)) {
|
||||
this.pinned = nextPinned
|
||||
this.publish({ type: 'pinned', pinnedSessions: pinnedSessionViews(state.pinnedSessions) })
|
||||
this.publish({ type: 'pinned', pinnedSessionIds: [...state.pinnedSessionIds] })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class WorkspaceController extends TypertRemoteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return one pinned Session to its natural list position.
|
||||
* Remove one Session's pin without changing its saved Session order.
|
||||
* @param request - Session identity to unpin.
|
||||
* @returns the complete resulting pin set, most recently pinned first.
|
||||
*/
|
||||
|
||||
@@ -119,20 +119,14 @@ export interface WorkspacePinSessionRequest {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** Session requested for unpinning back to its natural list position. */
|
||||
/** Session requested for removal from the pin set. */
|
||||
export interface WorkspaceUnpinSessionRequest {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** One pinned Session entry: the Session and the epoch-millisecond instant it was pinned at. */
|
||||
export interface WorkspacePinnedSession {
|
||||
readonly sessionId: SessionId
|
||||
readonly pinnedAt: number
|
||||
}
|
||||
|
||||
/** Complete pinned Session set after a mutation, most recently pinned first. */
|
||||
export interface WorkspacePinValue {
|
||||
readonly pinnedSessions: readonly WorkspacePinnedSession[]
|
||||
readonly pinnedSessionIds: readonly SessionId[]
|
||||
}
|
||||
|
||||
/** Complete reconnect baseline for Workspace browser state. */
|
||||
@@ -140,7 +134,7 @@ export interface WorkspaceBaseline {
|
||||
readonly items: readonly WorkspaceView[]
|
||||
readonly archivedSessionIds: readonly SessionId[]
|
||||
/** Registry-global pin set, most recently pinned first. */
|
||||
readonly pinnedSessions: readonly WorkspacePinnedSession[]
|
||||
readonly pinnedSessionIds: readonly SessionId[]
|
||||
}
|
||||
|
||||
/** One ordered Workspace change after a generation's baseline. */
|
||||
@@ -149,7 +143,7 @@ export type WorkspaceFollowIncrement =
|
||||
| { readonly type: 'remove'; readonly workspaceId: WorkspaceId }
|
||||
| { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] }
|
||||
| { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] }
|
||||
| { readonly type: 'pinned'; readonly pinnedSessions: readonly WorkspacePinnedSession[] }
|
||||
| { readonly type: 'pinned'; readonly pinnedSessionIds: readonly SessionId[] }
|
||||
|
||||
/** Workspace state stream; every generation starts with exactly one baseline. */
|
||||
export type WorkspaceFollowFrame =
|
||||
|
||||
@@ -20,7 +20,6 @@ import type {
|
||||
WorkspaceUnpinSessionRequest,
|
||||
WorkspaceValue,
|
||||
WorkspaceId,
|
||||
WorkspacePinnedSession,
|
||||
WorkspaceView,
|
||||
} from '../src/types.ts'
|
||||
import { RemoteError, type RemoteFailure, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
@@ -28,9 +27,6 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
const wid = (id: string): WorkspaceId => id as WorkspaceId
|
||||
const pin = (id: string, pinnedAt = 1): WorkspacePinnedSession => ({ sessionId: sid(id), pinnedAt })
|
||||
const pinnedIds = (entries: readonly WorkspacePinnedSession[]): string[] =>
|
||||
entries.map(entry => String(entry.sessionId))
|
||||
|
||||
function workspace(
|
||||
id: string,
|
||||
@@ -99,11 +95,11 @@ class FakeWorkspaceRemote implements WorkspaceRemote {
|
||||
onPinSession: (
|
||||
request: WorkspacePinSessionRequest,
|
||||
) => Promise<RemoteResult<WorkspacePinValue>> = request =>
|
||||
Promise.resolve(remoteOk({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] }))
|
||||
Promise.resolve(remoteOk({ pinnedSessionIds: [request.sessionId] }))
|
||||
onUnpinSession: (
|
||||
_request: WorkspaceUnpinSessionRequest,
|
||||
) => Promise<RemoteResult<WorkspacePinValue>> = () =>
|
||||
Promise.resolve(remoteOk({ pinnedSessions: [] }))
|
||||
Promise.resolve(remoteOk({ pinnedSessionIds: [] }))
|
||||
|
||||
create(request: WorkspaceCreateRequest): Promise<RemoteResult<WorkspaceCreateValue>> {
|
||||
this.record('create', request)
|
||||
@@ -165,9 +161,9 @@ function baseline(
|
||||
model: ClientWorkspaceModel,
|
||||
items: readonly WorkspaceView[] = [],
|
||||
archivedSessionIds: readonly SessionId[] = [],
|
||||
pinnedSessions: readonly WorkspacePinnedSession[] = [],
|
||||
pinnedSessionIds: readonly SessionId[] = [],
|
||||
): void {
|
||||
model.replaceBaseline({ items, archivedSessionIds, pinnedSessions })
|
||||
model.replaceBaseline({ items, archivedSessionIds, pinnedSessionIds })
|
||||
}
|
||||
|
||||
describe('ClientWorkspaceModel', () => {
|
||||
@@ -422,38 +418,36 @@ describe('ClientWorkspaceModel', () => {
|
||||
it('applies pin mutation echoes and leaves failed results unchanged', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
baseline(model, [], [], [pin('kept')])
|
||||
baseline(model, [], [], [sid('kept')])
|
||||
|
||||
remote.onPinSession = () => Promise.resolve(workspaceError(
|
||||
new RemoteError('session/not-found', 'missing', { sessionId: sid('missing') }),
|
||||
))
|
||||
await expect(model.pinSession(sid('missing'))).resolves.toMatchObject({ ok: false })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
|
||||
remote.onPinSession = request => Promise.resolve(remoteOk({
|
||||
pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 2 }, pin('kept')],
|
||||
pinnedSessionIds: [request.sessionId, sid('kept')],
|
||||
}))
|
||||
await expect(model.pinSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['fresh', 'kept'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['fresh', 'kept'])
|
||||
expect(remote.calls).toContainEqual({ method: 'pinSession', request: { sessionId: 'fresh' } })
|
||||
|
||||
remote.onUnpinSession = () => Promise.resolve(workspaceError(
|
||||
new RemoteError('gateway/internal', 'wire down', {}),
|
||||
))
|
||||
await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: false })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['fresh', 'kept'])
|
||||
remote.onUnpinSession = () => Promise.resolve(remoteOk({ pinnedSessions: [pin('kept')] }))
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['fresh', 'kept'])
|
||||
remote.onUnpinSession = () => Promise.resolve(remoteOk({ pinnedSessionIds: [sid('kept')] }))
|
||||
await expect(model.unpinSession(sid('fresh'))).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
|
||||
expect(remote.calls).toContainEqual({ method: 'unpinSession', request: { sessionId: 'fresh' } })
|
||||
|
||||
// A pushed set identical to the local one publishes no new snapshot; the
|
||||
// same member re-pinned at a new instant does.
|
||||
const before = model.getSnapshot()
|
||||
model.replacePinned([pin('kept')])
|
||||
model.replacePinned([sid('kept')])
|
||||
expect(model.getSnapshot()).toBe(before)
|
||||
model.replacePinned([pin('kept', 9)])
|
||||
model.replacePinned([sid('other')])
|
||||
expect(model.getSnapshot()).not.toBe(before)
|
||||
expect(model.getSnapshot().pinnedSessions).toEqual([pin('kept', 9)])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual([sid('other')])
|
||||
})
|
||||
|
||||
it('keeps the latest pin reply when overlapping requests settle out of order', async () => {
|
||||
@@ -466,36 +460,36 @@ describe('ClientWorkspaceModel', () => {
|
||||
|
||||
const first = model.pinSession(sid('first'))
|
||||
const second = model.pinSession(sid('second'))
|
||||
secondGate.resolve(remoteOk({ pinnedSessions: [pin('second', 2), pin('first')] }))
|
||||
secondGate.resolve(remoteOk({ pinnedSessionIds: [sid('second'), sid('first')] }))
|
||||
await expect(second).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['second', 'first'])
|
||||
firstGate.resolve(remoteOk({ pinnedSessions: [pin('first')] }))
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['second', 'first'])
|
||||
firstGate.resolve(remoteOk({ pinnedSessionIds: [sid('first')] }))
|
||||
await expect(first).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['second', 'first'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
it('keeps a pushed pin set when an unpin reply lands later', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
baseline(model, [], [], [pin('first')])
|
||||
baseline(model, [], [], [sid('first')])
|
||||
const gate = deferred<RemoteResult<WorkspacePinValue>>()
|
||||
remote.onUnpinSession = () => gate.promise
|
||||
|
||||
const pending = model.unpinSession(sid('first'))
|
||||
model.replacePinned([pin('first'), pin('second', 2)])
|
||||
gate.resolve(remoteOk({ pinnedSessions: [] }))
|
||||
model.replacePinned([sid('first'), sid('second')])
|
||||
gate.resolve(remoteOk({ pinnedSessionIds: [] }))
|
||||
await expect(pending).resolves.toMatchObject({ ok: true })
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['first', 'second'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('mirrors the Host pin drop locally when an archive echo lands', async () => {
|
||||
const remote = new FakeWorkspaceRemote()
|
||||
const model = modelFor(remote)
|
||||
baseline(model, [], [], [pin('pinned'), pin('kept')])
|
||||
baseline(model, [], [], [sid('pinned'), sid('kept')])
|
||||
|
||||
await expect(model.archiveSession(sid('pinned'))).resolves.toMatchObject({ ok: true })
|
||||
expect(model.getSnapshot().archivedSessionIds).toEqual(['pinned'])
|
||||
expect(pinnedIds(model.getSnapshot().pinnedSessions)).toEqual(['kept'])
|
||||
expect(model.getSnapshot().pinnedSessionIds).toEqual(['kept'])
|
||||
})
|
||||
|
||||
it('keeps the newest row and places Workspaces missing from partial orders last', async () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ export function workspace(id: string, overrides: Partial<WorkspaceView> = {}): W
|
||||
export function baseline(...ids: readonly string[]): WorkspaceBaselineFrame {
|
||||
return {
|
||||
type: 'baseline',
|
||||
value: { items: ids.map(id => workspace(id)), archivedSessionIds: [], pinnedSessions: [] },
|
||||
value: { items: ids.map(id => workspace(id)), archivedSessionIds: [], pinnedSessionIds: [] },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export const workspaceWorld: RemoteTable = {
|
||||
}),
|
||||
'workspace/archiveSession': (request: WorkspaceArchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [request.sessionId] }),
|
||||
'workspace/unarchiveSession': (_request: WorkspaceUnarchiveSessionRequest): RemoteResult<WorkspaceArchiveValue> => ok({ archivedSessionIds: [] }),
|
||||
'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessions: [{ sessionId: request.sessionId, pinnedAt: 1 }] }),
|
||||
'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessions: [] }),
|
||||
'workspace/pinSession': (request: WorkspacePinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessionIds: [request.sessionId] }),
|
||||
'workspace/unpinSession': (_request: WorkspaceUnpinSessionRequest): RemoteResult<WorkspacePinValue> => ok({ pinnedSessionIds: [] }),
|
||||
},
|
||||
}
|
||||
@@ -154,7 +154,7 @@ describe('Workspace state stream', () => {
|
||||
{ type: 'remove', workspaceId: view.workspaceId },
|
||||
{ type: 'order', workspaceIds: [view.workspaceId] },
|
||||
{ type: 'archived', archivedSessionIds: [sid('session-one')] },
|
||||
{ type: 'pinned', pinnedSessions: [{ sessionId: sid('session-two'), pinnedAt: 1 }] },
|
||||
{ type: 'pinned', pinnedSessionIds: [sid('session-two')] },
|
||||
]
|
||||
mock.stream(FOLLOW, openStream([opening, ...increments]))
|
||||
const replaceBaseline = vi.fn<WorkspaceFollowSink['replaceBaseline']>()
|
||||
@@ -177,7 +177,7 @@ describe('Workspace state stream', () => {
|
||||
expect(removeView).toHaveBeenCalledWith(view.workspaceId)
|
||||
expect(replaceOrder).toHaveBeenCalledWith([view.workspaceId])
|
||||
expect(replaceArchived).toHaveBeenCalledWith(['session-one'])
|
||||
expect(replacePinned).toHaveBeenCalledWith([{ sessionId: 'session-two', pinnedAt: 1 }])
|
||||
expect(replacePinned).toHaveBeenCalledWith(['session-two'])
|
||||
await stream.dispose()
|
||||
expect(streamStates(mock)).toEqual(['cancelled'])
|
||||
})
|
||||
@@ -313,7 +313,7 @@ describe('WorkspaceController', () => {
|
||||
it('publishes the model source and exposes successful Workspace commands', async ({ mock, start }) => {
|
||||
const { remote, client } = await gatewayClient(mock, start)
|
||||
const model = new ClientWorkspaceModel(remote.workspace)
|
||||
model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [], pinnedSessions: [] })
|
||||
model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [], pinnedSessionIds: [] })
|
||||
const controller = new WorkspaceController(client.ctx, model)
|
||||
|
||||
expect(controller.list).toBe(model)
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('WorkspaceController commands', () => {
|
||||
})
|
||||
|
||||
await expect(controller.pinSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ pinnedSessions: [{ sessionId: session.id, pinnedAt: expect.any(Number) }] })
|
||||
.resolves.toEqual({ pinnedSessionIds: [session.id] })
|
||||
await expect(controller.pinSession({ sessionId: SessionId('unknown') }))
|
||||
.rejects.toMatchObject({ code: 'session/not-found' })
|
||||
|
||||
@@ -264,10 +264,10 @@ describe('WorkspaceController commands', () => {
|
||||
.rejects.toMatchObject({ code: 'gateway/bad-request' })
|
||||
|
||||
await expect(controller.unpinSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ pinnedSessions: [] })
|
||||
.resolves.toEqual({ pinnedSessionIds: [] })
|
||||
// Unpin is idempotent: an id that is not pinned is not an error.
|
||||
await expect(controller.unpinSession({ sessionId: session.id }))
|
||||
.resolves.toEqual({ pinnedSessions: [] })
|
||||
.resolves.toEqual({ pinnedSessionIds: [] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -290,19 +290,38 @@ describe('WorkspaceController follow', () => {
|
||||
initialized: true,
|
||||
workspaceIds: ['missing'],
|
||||
archivedSessionIds: [],
|
||||
pinnedSessions: [],
|
||||
pinnedSessionIds: [],
|
||||
},
|
||||
})
|
||||
}).toThrow('references missing Workspace "missing"')
|
||||
})
|
||||
|
||||
it('starts a fresh feed with existing pins and follows their removal', async () => {
|
||||
const { controller, ctx, root } = await harness()
|
||||
const session = ctx.sessions.create(SessionId('already-pinned'), { meta: { cwd: root } })
|
||||
await controller.pinSession({ sessionId: session.id })
|
||||
const feed = new WorkspaceFeed(ctx)
|
||||
const abort = new AbortController()
|
||||
const iterator = feed.follow(abort.signal)[Symbol.asyncIterator]()
|
||||
try {
|
||||
await expect(nextFrame(iterator)).resolves.toMatchObject({
|
||||
type: 'baseline', value: { pinnedSessionIds: [session.id] },
|
||||
})
|
||||
await controller.unpinSession({ sessionId: session.id })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [] })
|
||||
} finally {
|
||||
abort.abort()
|
||||
await iterator.return?.()
|
||||
}
|
||||
})
|
||||
|
||||
it('starts with a complete baseline and emits committed increments in domain order', async () => {
|
||||
const { controller, ctx, root } = await harness()
|
||||
const abort = new AbortController()
|
||||
const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]()
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'baseline',
|
||||
value: { items: [], archivedSessionIds: [], pinnedSessions: [] },
|
||||
value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] },
|
||||
})
|
||||
|
||||
const first = await controller.create({ path: stageDir(root, 'first') })
|
||||
@@ -347,12 +366,12 @@ describe('WorkspaceController follow', () => {
|
||||
})
|
||||
await controller.pinSession({ sessionId: session.id })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'pinned', pinnedSessions: [{ sessionId: session.id, pinnedAt: expect.any(Number) }],
|
||||
type: 'pinned', pinnedSessionIds: [session.id],
|
||||
})
|
||||
// Unpin rides the same complete-set increment: no new frame type.
|
||||
await controller.unpinSession({ sessionId: session.id })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
type: 'pinned', pinnedSessions: [],
|
||||
type: 'pinned', pinnedSessionIds: [],
|
||||
})
|
||||
await controller.delete({ workspaceId: second.workspace.workspaceId })
|
||||
await expect(nextFrame(iterator)).resolves.toEqual({
|
||||
|
||||
Reference in new issue
Block a user