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
@@ -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