Merge branch 'master' into worktree/i18n-complete-non-readme
This commit is contained in:
@@ -15,6 +15,17 @@ import type { WorkspaceRecord } from './spec.ts'
|
||||
import type { Workspace, WorkspaceId } from './types.ts'
|
||||
import { realpathNormalize } from './paths.ts'
|
||||
|
||||
/** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */
|
||||
export class WorkspaceMoveInvalidError extends Error {
|
||||
/**
|
||||
* @param message - Which id was unaccounted and where.
|
||||
*/
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'WorkspaceMoveInvalidError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry-owned machinery an entity mutates through. Entities never see
|
||||
* the registry itself — only the open table, the canonical session-path
|
||||
@@ -137,30 +148,27 @@ export class WorkspaceEntity implements Workspace {
|
||||
: { ...record, sessionIds: [sessionId, ...record.sessionIds] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the durable candidate account without applying header projection.
|
||||
* @param sessionId - Candidate session id.
|
||||
* @returns whether this workspace's stored account contains the id.
|
||||
*/
|
||||
hasSession(sessionId: SessionId): boolean {
|
||||
return this.record.sessionIds.includes(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one validated accounted session to the front without touching peers.
|
||||
* @param sessionId - Accounted session whose activity was observed.
|
||||
*/
|
||||
async touchSession(sessionId: SessionId): Promise<void> {
|
||||
if (
|
||||
this.host.sessionPath(sessionId) !== this.record.path
|
||||
|| this.record.sessionIds[0] === sessionId
|
||||
) return
|
||||
await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId
|
||||
? record
|
||||
: {
|
||||
...record,
|
||||
sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)],
|
||||
})
|
||||
async insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void> {
|
||||
await this.mutate((record) => {
|
||||
if (!record.sessionIds.includes(sessionId)) {
|
||||
throw new WorkspaceMoveInvalidError(
|
||||
`cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`,
|
||||
)
|
||||
}
|
||||
if (beforeSessionId !== undefined && !record.sessionIds.includes(beforeSessionId)) {
|
||||
throw new WorkspaceMoveInvalidError(
|
||||
`cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': `
|
||||
+ 'the anchor session is not accounted',
|
||||
)
|
||||
}
|
||||
if (beforeSessionId === sessionId) return record
|
||||
const without = record.sessionIds.filter(id => id !== sessionId)
|
||||
const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId)
|
||||
const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)]
|
||||
return sessionIds.every((id, index) => id === record.sessionIds[index])
|
||||
? record
|
||||
: { ...record, sessionIds }
|
||||
})
|
||||
}
|
||||
|
||||
async detachSession(sessionId: SessionId): Promise<void> {
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { WorkspaceEntity } from './entity.ts'
|
||||
import type { WorkspaceEntityHost } from './entity.ts'
|
||||
|
||||
export { WorkspaceMoveInvalidError } from './entity.ts'
|
||||
import { realpathNormalize } from './paths.ts'
|
||||
import { workspaceDomainSpec } from './spec.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
|
||||
@@ -47,6 +49,7 @@ export class WorkspaceNameConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
workspace: WorkspaceRegistry
|
||||
@@ -82,7 +85,6 @@ export class WorkspaceRegistry extends Service {
|
||||
private readonly headers = new Map<SessionId, SessionHeader>()
|
||||
private readonly sessionPaths = new Map<SessionId, string>()
|
||||
private readonly invalidSessionPaths = new Map<SessionId, string>()
|
||||
private readonly pendingTouches = new Map<SessionId, Promise<void>>()
|
||||
private operationTail: Promise<void> = Promise.resolve()
|
||||
|
||||
private readonly host: WorkspaceEntityHost = {
|
||||
@@ -120,13 +122,6 @@ export class WorkspaceRegistry extends Service {
|
||||
this.validateStoredState(this.requireState())
|
||||
this.rebuildEntities()
|
||||
this.reportFilteredCandidates()
|
||||
// Session activity is authoritative even when no RPC/SSE consumer is
|
||||
// connected. This service-owned listener is disposed with the registry.
|
||||
this.ctx.on('session/event', (session) => {
|
||||
void this.touchSession(session.id).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,32 +168,6 @@ export class WorkspaceRegistry extends Service {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one accounted, cwd-validated session to the front of its workspace.
|
||||
* Ungrouped sessions and candidates filtered by the header check are
|
||||
* no-ops. The owning workspace's relative position never changes.
|
||||
* @param sessionId - Session whose activity was observed.
|
||||
* @returns resolution after the possible record write.
|
||||
*/
|
||||
async touchSession(sessionId: SessionId): Promise<void> {
|
||||
const pending = this.pendingTouches.get(sessionId)
|
||||
if (pending !== undefined) {
|
||||
await pending
|
||||
return
|
||||
}
|
||||
for (const entity of this.entities.values()) {
|
||||
if (!entity.hasSession(sessionId)) continue
|
||||
const touch = entity.touchSession(sessionId)
|
||||
this.pendingTouches.set(sessionId, touch)
|
||||
try {
|
||||
await touch
|
||||
} finally {
|
||||
this.pendingTouches.delete(sessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve by canonical directory path without creating or mutating a
|
||||
* workspace. A missing path rejects during `realpath`; an existing unowned
|
||||
|
||||
@@ -41,10 +41,12 @@ export interface Workspace {
|
||||
readonly updatedAt: string
|
||||
|
||||
/**
|
||||
* Header-validated sessions in newest-first display order. The durable
|
||||
* candidate account is filtered synchronously: missing headers, invalid
|
||||
* cwd values, and canonical cwd mismatches are never returned. A subsequent
|
||||
* workspace mutation prunes those filtered candidates durably.
|
||||
* Header-validated sessions in manually owned order: a new session is
|
||||
* prepended at attach, explicit reordering goes through
|
||||
* `insertSessionBefore`, and activity never reorders. The durable candidate
|
||||
* account is filtered synchronously: missing headers, invalid cwd values,
|
||||
* and canonical cwd mismatches are never returned. A subsequent workspace
|
||||
* mutation prunes those filtered candidates durably.
|
||||
*/
|
||||
readonly sessionIds: readonly SessionId[]
|
||||
|
||||
@@ -57,8 +59,7 @@ export interface Workspace {
|
||||
|
||||
/**
|
||||
* Prepend a session to this workspace's candidate account. An already
|
||||
* accounted id resolves without writing; activity-driven reordering uses
|
||||
* `WorkspaceRegistry.touchSession` instead. A new id's live or persisted
|
||||
* accounted id resolves without writing. A new id's live or persisted
|
||||
* header cwd must resolve to an existing directory equal to {@link path};
|
||||
* unknown ids, missing or invalid cwd values, and mismatches reject without
|
||||
* writing.
|
||||
@@ -67,6 +68,18 @@ export interface Workspace {
|
||||
*/
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Move an accounted session within the manual order, DOM-insertBefore-like:
|
||||
* with an anchor the session lands before it, without one it appends to the
|
||||
* end. Only the moved id changes position. A session or anchor absent from
|
||||
* the account rejects without writing; a move to the current position
|
||||
* resolves without writing (decided on the domain write chain).
|
||||
* @param sessionId - The accounted session to move.
|
||||
* @param beforeSessionId - Accounted anchor to insert before; omitted appends.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Remove a session from this workspace's account. Idempotent: an id not on
|
||||
* the account resolves without writing (decided on the domain write chain,
|
||||
|
||||
@@ -10,8 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import { WorkspaceEntity } from '../src/entity.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts'
|
||||
import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
|
||||
|
||||
const DOMAIN_VERSION = 2
|
||||
@@ -434,13 +433,12 @@ describe('WorkspaceRegistry create and lookup', () => {
|
||||
})
|
||||
|
||||
describe('Workspace session ordering', () => {
|
||||
it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => {
|
||||
it('prepends new attaches and keeps repeat attach idempotent', async () => {
|
||||
const dir = await makeDir('attach-order')
|
||||
const result = await harness()
|
||||
result.setSessions([
|
||||
header('s1', dir, 1),
|
||||
header('s2', dir, 2),
|
||||
header('ungrouped', dir, 3),
|
||||
])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
@@ -448,59 +446,57 @@ describe('Workspace session ordering', () => {
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
|
||||
const beforeTouch = result.changes.length
|
||||
await Promise.all([
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
])
|
||||
expect(workspace.sessionIds).toEqual(['s1', 's2'])
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
await result.registry.touchSession(SessionId('s1'))
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
await result.registry.touchSession(SessionId('ungrouped'))
|
||||
expect(result.changes).toHaveLength(beforeTouch + 1)
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2'])
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1'])
|
||||
})
|
||||
|
||||
it('does not resurrect a session detached before its queued touch', async () => {
|
||||
const dir = await makeDir('detach-touch-race')
|
||||
const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] })
|
||||
it('moves one id before an anchor or to the end, durably', async () => {
|
||||
const dir = await makeDir('insert-before')
|
||||
const result = await harness()
|
||||
result.setSessions([header('s1', dir, 1), header('s2', dir, 2), header('s3', dir, 3)])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
await workspace.attachSession(SessionId('s2'))
|
||||
await workspace.attachSession(SessionId('s3'))
|
||||
expect(workspace.sessionIds).toEqual(['s3', 's2', 's1'])
|
||||
|
||||
await workspace.insertSessionBefore(SessionId('s1'), SessionId('s2'))
|
||||
expect(workspace.sessionIds).toEqual(['s3', 's1', 's2'])
|
||||
await workspace.insertSessionBefore(SessionId('s3'))
|
||||
expect(workspace.sessionIds).toEqual(['s1', 's2', 's3'])
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2', 's3'])
|
||||
})
|
||||
|
||||
it('treats self-anchored and already-in-place moves as no-ops without writing', async () => {
|
||||
const dir = await makeDir('insert-noop')
|
||||
const result = await harness()
|
||||
result.setSessions([header('s1', dir, 1), header('s2', dir, 2)])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
await workspace.attachSession(SessionId('s2'))
|
||||
await Promise.all([
|
||||
workspace.detachSession(SessionId('s1')),
|
||||
result.registry.touchSession(SessionId('s1')),
|
||||
])
|
||||
const written = result.changes.length
|
||||
|
||||
await workspace.insertSessionBefore(SessionId('s1'), SessionId('s1'))
|
||||
await workspace.insertSessionBefore(SessionId('s2'), SessionId('s1'))
|
||||
await workspace.insertSessionBefore(SessionId('s1'))
|
||||
await workspace.detachSession(SessionId('absent'))
|
||||
expect(result.changes).toHaveLength(written)
|
||||
expect(workspace.sessionIds).toEqual(['s2'])
|
||||
expect(workspace.sessionIds).toEqual(['s2', 's1'])
|
||||
})
|
||||
|
||||
it('does not reinsert a candidate absent at the durable touch slot', async () => {
|
||||
const dir = await makeDir('stale-touch')
|
||||
const id = WorkspaceId('00000000-0000-4000-8000-000000000030')
|
||||
let durable = record(dir, ['s2', 's1'])
|
||||
const table = {
|
||||
update: async (
|
||||
_id: WorkspaceId,
|
||||
update: (current: WorkspaceRecord) => WorkspaceRecord,
|
||||
): Promise<WorkspaceRecord> => {
|
||||
durable = { ...durable, sessionIds: [SessionId('s2')] }
|
||||
durable = update(durable)
|
||||
return durable
|
||||
},
|
||||
}
|
||||
const entity = new WorkspaceEntity({
|
||||
table: () => table as never,
|
||||
sessionPath: () => dir,
|
||||
readSessionHeader: async () => header('s1', dir),
|
||||
rememberSessionPath: () => {},
|
||||
}, id, record(dir, ['s2', 's1']))
|
||||
await entity.touchSession(SessionId('s1'))
|
||||
expect(durable.sessionIds).toEqual(['s2'])
|
||||
it('rejects moves naming an unaccounted session or anchor', async () => {
|
||||
const dir = await makeDir('insert-invalid')
|
||||
const result = await harness()
|
||||
result.setSessions([header('s1', dir, 1)])
|
||||
const workspace = await result.registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
const written = result.changes.length
|
||||
|
||||
await expect(workspace.insertSessionBefore(SessionId('ghost')))
|
||||
.rejects.toBeInstanceOf(WorkspaceMoveInvalidError)
|
||||
await expect(workspace.insertSessionBefore(SessionId('s1'), SessionId('ghost')))
|
||||
.rejects.toThrow(/anchor session is not accounted/)
|
||||
expect(result.changes).toHaveLength(written)
|
||||
expect(workspace.sessionIds).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('validates a lazy live session without requiring it in persistence.list()', async () => {
|
||||
@@ -546,66 +542,6 @@ describe('Workspace session ordering', () => {
|
||||
expect(workspace.sessionIds).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('keeps workspace order stable while touch order survives reload', async () => {
|
||||
const older = await makeDir('stable-older')
|
||||
const newer = await makeDir('stable-newer')
|
||||
const sessions = [
|
||||
header('old-1', older, 100),
|
||||
header('old-2', older, 200),
|
||||
header('new-1', newer, 300),
|
||||
]
|
||||
const pool = new MemoryMediaPool()
|
||||
const first = await harness({ pool, sessions })
|
||||
const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id)
|
||||
const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)!
|
||||
expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1'])
|
||||
await first.registry.touchSession(SessionId('old-1'))
|
||||
expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2'])
|
||||
expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
|
||||
await first.fiber.dispose()
|
||||
|
||||
const reloaded = await harness({ pool, sessions })
|
||||
expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
|
||||
expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds)
|
||||
.toEqual(['old-1', 'old-2'])
|
||||
})
|
||||
|
||||
it('persists activity order from session/event without any stream consumer', async () => {
|
||||
const dir = await makeDir('event-touch')
|
||||
const result = await harness({ sessionStore: true })
|
||||
const workspace = await result.registry.create(dir)
|
||||
const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } })
|
||||
result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } })
|
||||
await workspace.attachSession(SessionId('event-first'))
|
||||
await workspace.attachSession(SessionId('event-second'))
|
||||
expect(workspace.sessionIds).toEqual(['event-second', 'event-first'])
|
||||
|
||||
first.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) })
|
||||
expect(storedRecord(result.pool, workspace.id).sessionIds)
|
||||
.toEqual(['event-first', 'event-second'])
|
||||
})
|
||||
|
||||
it('contains a background activity write failure at the service listener', async () => {
|
||||
const dir = await makeDir('event-touch-failure')
|
||||
const result = await harness({ sessionStore: true })
|
||||
const workspace = await result.registry.create(dir)
|
||||
const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } })
|
||||
result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } })
|
||||
await workspace.attachSession(SessionId('failed-first'))
|
||||
await workspace.attachSession(SessionId('failed-second'))
|
||||
const warn = vi.spyOn(result.ctx.logger, 'warn')
|
||||
result.pool.failNextWrites = 1
|
||||
first.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) })
|
||||
expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('header-validated membership projection', () => {
|
||||
|
||||
Reference in New Issue
Block a user