fix(deliverables): satisfy the static gates and Windows tests

Regenerate the client and persistence catalogs, document the review address builder with a plain parameter, share the changed-file coordinate parsing between the comparison and native-open routes, and fold the summary and comparison stores onto one Host read store. A path with no existing ancestor but the filesystem root keeps its spelling on every platform, and the temp-root test redirects TMP and TEMP as well as TMPDIR while counting only the recorder's own directories.
This commit is contained in:
creatixchu committed 2026-09-16 19:32:25 +08:00
1 parent 2875e28a0e
commit 974d0271c0
13 files changed
+162 -127

No files matched your search

+2 -2
View File
@@ -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 docs/persistence-catalog.md
persistence-catalog.md: 637dcfcf48d2699b0072d087c7a765fe900a7684
persistence-catalog.zh.md: e8af53ae630ac7b061e9d158eef8f26d2a5fdde4
persistence-catalog.md: 0957adb0ee15587eedff2b38742616bc4c8decba
persistence-catalog.zh.md: 45e7155b0ddf8d72ac132e0ac79ea31c28acea3c
+2 -2
View File
@@ -1256,7 +1256,7 @@ Source: [`packages/web/web-search-deepseek/src/provider.ts:82`](../packages/web/
'workspace/changes': { turn: number }
```
Source: [`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts)
Source: [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts)
## Resolved persistence types
@@ -3920,7 +3920,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d`
Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts)
Sources: [`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+2 -2
View File
@@ -1258,7 +1258,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
'workspace/changes': { turn: number }
```
来源:[`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts)
来源:[`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts)
## 已解析的持久化类型
@@ -3922,7 +3922,7 @@ SHA-256: `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8`
SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d`
来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:58`](../packages/deliverables/workspace-changes/src/types.ts)
来源:[`packages/core/session/src/types.ts:276`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+1 -1
View File
@@ -60698,7 +60698,7 @@
],
"sources": [
"packages/core/session/src/types.ts:276",
"packages/deliverables/workspace-changes/src/types.ts:58"
"packages/deliverables/workspace-changes/src/types.ts:106"
]
},
{
@@ -128,7 +128,8 @@ export function changedFileUrl(sessionId: SessionId, seq: number, index: number)
* @param coordinates - viewed Session, announcing event, and turn.
* @returns a `dsh-resource://changes-review/session/…` address.
*/
export function changesReviewAddress({ sessionId, seq, turn }: ChangesReviewCoordinates): string {
export function changesReviewAddress(coordinates: ChangesReviewCoordinates): string {
const { sessionId, seq, turn } = coordinates
return `${CHANGES_REVIEW_ADDRESS}${encodeURIComponent(sessionId)}/${seq}/${turn}`
}
@@ -1,7 +1,7 @@
/** Cache of the file comparisons the Host serves for listed changed files, read once per comparison and again on request. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
/** Cache of the file comparisons the Host serves for listed changed files, read once per comparison and again after a failure. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { changesDiffUrl, isChangesDiff, type ChangesDiff } from '../changes.ts'
import { HostReadStore } from './host-read-store.ts'
/**
* A comparison, `'missing'` once the Host no longer serves it, `'error'` for
@@ -9,14 +9,21 @@ import { changesDiffUrl, isChangesDiff, type ChangesDiff } from '../changes.ts'
*/
export type ChangesDiffState = ChangesDiff | 'missing' | 'error' | 'loading'
/** One browser plugin's comparison reads, cleared on connection replacement and cancelled on disposal. */
export class ChangesDiffStore {
/** Comparison URLs key the state across Sessions, turns, and files. */
readonly state = createSnapshotStore<Record<string, ChangesDiffState | undefined>>({})
private readonly lifetime = new AbortController()
/** The connection generation the current states belong to; a reset aborts it so no older read publishes. */
private generation = new AbortController()
private readonly pending = new Set<Promise<void>>()
/** One browser plugin's comparison reads; a failed read is the one state a later request replaces. */
export class ChangesDiffStore extends HostReadStore<ChangesDiffState> {
constructor() {
super({
loading: 'loading',
failed: 'error',
retryable: state => state === 'error',
decode: async (response) => {
if (response.status === 404) return 'missing'
if (!response.ok) return 'error'
const value: unknown = await response.json()
return isChangesDiff(value) ? value : 'error'
},
})
}
/**
* Read one comparison; a cached comparison or a missing one is kept, a failed one is read again.
@@ -25,46 +32,7 @@ export class ChangesDiffStore {
* @param index - the file's index in the summary.
* @returns after the state is published.
*/
async load(sessionId: SessionId, seq: number, index: number): Promise<void> {
const url = changesDiffUrl(sessionId, seq, index)
const current = this.state.getSnapshot()[url]
if (this.lifetime.signal.aborted || (current !== undefined && current !== 'error')) return
this.state.update((state) => { state[url] = 'loading' })
const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal]))
this.pending.add(task)
try {
await task
} finally {
this.pending.delete(task)
}
}
/** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */
reset(): void {
this.generation.abort()
this.generation = new AbortController()
this.state.set({})
}
/** Cancel outstanding reads and wait until none can publish state. */
async dispose(): Promise<void> {
this.lifetime.abort()
await Promise.all(this.pending)
}
private async read(url: string, signal: AbortSignal): Promise<void> {
let next: ChangesDiffState = 'error'
try {
const response = await fetch(url, { signal })
if (response.status === 404) next = 'missing'
else if (response.ok) {
const value: unknown = await response.json()
if (isChangesDiff(value)) next = value
}
} catch {
// A transport failure stays retryable until the connection is replaced and the store reset.
next = 'error'
}
if (!signal.aborted) this.state.update((state) => { state[url] = next })
load(sessionId: SessionId, seq: number, index: number): Promise<void> {
return this.loadUrl(changesDiffUrl(sessionId, seq, index))
}
}
@@ -1,19 +1,25 @@
/** Fetch-once cache of the change summaries the Host serves for announced `workspace/changes` events. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { changesSummaryUrl, isChangesSummary, type ChangesSummary } from '../changes.ts'
import { HostReadStore } from './host-read-store.ts'
/** A summary, `'missing'` once the Host no longer serves it, or `'loading'` while the request runs. */
export type ChangesSummaryState = ChangesSummary | 'missing' | 'loading'
/** One browser plugin's summary reads, cleared on connection replacement and cancelled on disposal. */
export class ChangesSummaryStore {
/** Summary URLs key the state across Sessions and turns. */
readonly state = createSnapshotStore<Record<string, ChangesSummaryState | undefined>>({})
private readonly lifetime = new AbortController()
/** The connection generation the current states belong to; a reset aborts it so no older read publishes. */
private generation = new AbortController()
private readonly pending = new Set<Promise<void>>()
/** One browser plugin's summary reads; a summary or a missing answer is kept until the connection is replaced. */
export class ChangesSummaryStore extends HostReadStore<ChangesSummaryState> {
constructor() {
super({
loading: 'loading',
failed: 'missing',
retryable: () => false,
decode: async (response) => {
if (!response.ok) return 'missing'
const value: unknown = await response.json()
return isChangesSummary(value) ? value : 'missing'
},
})
}
/**
* Read one summary once; a later read of the same coordinates returns the cached state.
@@ -21,44 +27,7 @@ export class ChangesSummaryStore {
* @param seq - the announcing event's sequence.
* @returns after the state is published.
*/
async load(sessionId: SessionId, seq: number): Promise<void> {
const url = changesSummaryUrl(sessionId, seq)
if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] !== undefined) return
this.state.update((state) => { state[url] = 'loading' })
const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal]))
this.pending.add(task)
try {
await task
} finally {
this.pending.delete(task)
}
}
/** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */
reset(): void {
this.generation.abort()
this.generation = new AbortController()
this.state.set({})
}
/** Cancel outstanding reads and wait until none can publish state. */
async dispose(): Promise<void> {
this.lifetime.abort()
await Promise.all(this.pending)
}
private async read(url: string, signal: AbortSignal): Promise<void> {
let next: ChangesSummaryState = 'missing'
try {
const response = await fetch(url, { signal })
if (response.ok) {
const value: unknown = await response.json()
if (isChangesSummary(value)) next = value
}
} catch {
// A transport failure reads as missing until the connection is replaced and the store reset.
next = 'missing'
}
if (!signal.aborted) this.state.update((state) => { state[url] = next })
load(sessionId: SessionId, seq: number): Promise<void> {
return this.loadUrl(changesSummaryUrl(sessionId, seq))
}
}
@@ -0,0 +1,81 @@
/**
* Fetch-once cache of Host-served records keyed by their authenticated URL:
* one read per URL while a state stands, cleared on connection replacement,
* cancelled on disposal. Each store decides what a response means and which
* states a later request reads again.
*/
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
/** What one store makes of its reads. */
export interface HostReadPolicy<T> {
/** The state published while a request runs. */
loading: T
/** The state a transport failure publishes. */
failed: T
/**
* Whether a later request replaces a standing state with a new read.
* @param state - the standing state.
* @returns true to read again.
*/
retryable(state: T): boolean
/**
* Turn a settled response into a state.
* @param response - the Host's answer.
* @returns the state to publish.
*/
decode(response: Response): Promise<T>
}
/** One browser plugin's reads of one record kind. */
export class HostReadStore<T> {
/** Record URLs key the state across Sessions and turns. */
readonly state = createSnapshotStore<Record<string, T | undefined>>({})
private readonly lifetime = new AbortController()
/** The connection generation the current states belong to; a reset aborts it so no older read publishes. */
private generation = new AbortController()
private readonly pending = new Set<Promise<void>>()
constructor(private readonly policy: HostReadPolicy<T>) {}
/**
* Read one URL unless a state the policy keeps already stands for it.
* @param url - the record's authenticated URL.
* @returns after the state is published.
*/
protected async loadUrl(url: string): Promise<void> {
const current = this.state.getSnapshot()[url]
if (this.lifetime.signal.aborted || (current !== undefined && !this.policy.retryable(current))) return
this.state.update((state) => { state[url] = this.policy.loading })
const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal]))
this.pending.add(task)
try {
await task
} finally {
this.pending.delete(task)
}
}
/** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */
reset(): void {
this.generation.abort()
this.generation = new AbortController()
this.state.set({})
}
/** Cancel outstanding reads and wait until none can publish state. */
async dispose(): Promise<void> {
this.lifetime.abort()
await Promise.all(this.pending)
}
private async read(url: string, signal: AbortSignal): Promise<void> {
let next: T
try {
next = await this.policy.decode(await fetch(url, { signal }))
} catch {
// A transport failure publishes the policy's failed state until the connection is replaced and the store reset.
next = this.policy.failed
}
if (!signal.aborted) this.state.update((state) => { state[url] = next })
}
}
@@ -133,15 +133,23 @@ function handleChangesSummary(ctx: Context, request: Request): Response {
return Response.json({ turn, files, total, added, deleted } satisfies ChangesSummary, { headers: { 'cache-control': 'no-store' } })
}
/** One listed file's comparison; 404 once the Host no longer serves the summary or the index names no file. */
async function handleChangesDiff(ctx: Context, request: Request): Promise<Response> {
/** A changed file's coordinates from a route query, or the 400 to answer with. */
function changedFileCoordinates(request: Request): { id: SessionId; seq: number; index: number } | Response {
const query = new URL(request.url).searchParams
const id = query.get('sessionId')
const seq = coordinate(query.get('seq'))
const index = coordinate(query.get('index'))
if (!id || seq === undefined || index === undefined) return new Response('Invalid changed file coordinates.', { status: 400 })
return { id: id as SessionId, seq, index }
}
/** One listed file's comparison; 404 once the Host no longer serves the summary or the index names no file. */
async function handleChangesDiff(ctx: Context, request: Request): Promise<Response> {
const coordinates = changedFileCoordinates(request)
if (coordinates instanceof Response) return coordinates
const { id, seq, index } = coordinates
try {
const diff = await ctx.workspaceChanges.diff(id as SessionId, seq, index, request.signal)
const diff = await ctx.workspaceChanges.diff(id, seq, index, request.signal)
if (diff === undefined) return new Response('Change comparison unavailable.', { status: 404 })
return Response.json(diff, { headers: { 'cache-control': 'no-store' } })
} catch (error: unknown) {
@@ -151,20 +159,18 @@ async function handleChangesDiff(ctx: Context, request: Request): Promise<Respon
}
async function handleChangesOpen(ctx: Context, request: Request): Promise<Response> {
const query = new URL(request.url).searchParams
const id = query.get('sessionId')
const seq = coordinate(query.get('seq'))
const index = coordinate(query.get('index'))
if (!id || seq === undefined || index === undefined) return new Response('Invalid changed file coordinates.', { status: 400 })
const coordinates = changedFileCoordinates(request)
if (coordinates instanceof Response) return coordinates
const { id, seq, index } = coordinates
try {
request.signal.throwIfAborted()
if (!ctx.sessionController.workspaceDesktop().available) return new Response('Host desktop unavailable.', { status: 409 })
const changes = ctx.workspaceChanges.summary(id as SessionId, seq)
const changes = ctx.workspaceChanges.summary(id, seq)
if (changes === undefined) return new Response('Change summary unavailable.', { status: 404 })
const workspaceRoot = changes.cwd
const file = changes.files[index]
if (file === undefined) return new Response('Changed file not found in this summary.', { status: 404 })
const { absolutePath: path } = await ctx.workspaceFiles.stat({ sessionId: id as SessionId, workspaceRoot }, file.path, request.signal)
const { absolutePath: path } = await ctx.workspaceFiles.stat({ sessionId: id, workspaceRoot }, file.path, request.signal)
return await openVerified(ctx, request, path, 'open')
} catch (error: unknown) {
request.signal.throwIfAborted()
@@ -53,10 +53,10 @@ export async function canonicalPath(path: string): Promise<string> {
try {
return join(await realpath(head), ...missing)
} catch {
// A missing or unreadable component is kept lexically under its nearest resolvable ancestor.
// A missing or unreadable component is kept lexically under its nearest resolvable ancestor;
// a path with no existing ancestor but the root keeps its spelling entirely.
const parent = dirname(head)
/* v8 ignore next -- the filesystem root always resolves, so the walk ends before reaching it. */
if (parent === head) return path
if (parent === head || dirname(parent) === parent) return path
missing.unshift(basename(head))
head = parent
}
@@ -55,7 +55,9 @@ describe('canonicalPath', () => {
expect(await canonicalPath(join(root, 'link', 'nested'))).toBe(join(resolvedReal, 'nested'))
expect(await canonicalPath(join(root, 'link', 'nested', 'new.txt'))).toBe(join(resolvedReal, 'nested', 'new.txt'))
expect(await canonicalPath(join(root, 'link', 'missing', 'deeper', 'new.txt'))).toBe(join(resolvedReal, 'missing', 'deeper', 'new.txt'))
// Nothing but the root exists above this path, so its spelling is kept as given on every platform.
expect(await canonicalPath('/definitely/missing/root/file')).toBe('/definitely/missing/root/file')
expect(await canonicalPath(join(root, 'link', 'a'))).toBe(join(resolvedReal, 'a'))
})
})
@@ -374,9 +374,16 @@ describe('workspace-changes without a repository', () => {
const cwd = await scratchDir('dsh-workspace-changes-bounds-', cleanups)
// The recorder places its temporary directory under the platform temp root; point that root at a scratch directory.
const tempRoot = await scratchDir('dsh-workspace-changes-temp-', cleanups)
const previousTmp = process.env.TMPDIR
process.env.TMPDIR = tempRoot
cleanups.push(async () => { if (previousTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = previousTmp })
// POSIX reads TMPDIR, Windows reads TMP then TEMP.
const previous = { TMPDIR: process.env.TMPDIR, TMP: process.env.TMP, TEMP: process.env.TEMP }
for (const name of ['TMPDIR', 'TMP', 'TEMP'] as const) process.env[name] = tempRoot
cleanups.push(async () => {
for (const name of ['TMPDIR', 'TMP', 'TEMP'] as const) {
if (previous[name] === undefined) Reflect.deleteProperty(process.env, name); else process.env[name] = previous[name]
}
})
// Other tooling may create its own directories under the redirected temp root; only the recorder's count.
const recorderDirs = async (): Promise<string[]> => (await readdir(tempRoot)).filter(name => name.startsWith('dsh-workspace-changes-'))
await writeFile(join(cwd, 'grows.txt'), 'small\n')
await writeFile(join(cwd, 'huge.txt'), 'a'.repeat(20))
await writeFile(join(cwd, 'mixed.dat'), Uint8Array.of(65, 0, 66))
@@ -412,12 +419,12 @@ describe('workspace-changes without a repository', () => {
}
expect(await ctx.workspaceChanges.diff(session.id, seq, 4, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+x'] }] })
// Every copy lives under the Session's temporary directory and goes with it.
const [scratch, ...others] = await readdir(tempRoot)
const [scratch, ...others] = await recorderDirs()
expect(others).toEqual([])
expect(scratch).toMatch(/^dsh-workspace-changes-/)
expect((await readdir(join(tempRoot, scratch!, 'captures'))).length).toBeGreaterThan(0)
expect(scratch).toBeDefined()
expect((await readdir(join(tempRoot, scratch as string, 'captures'))).length).toBeGreaterThan(0)
ctx.emit('session/disposed', session)
await vi.waitFor(async () => { expect(await readdir(tempRoot)).toEqual([]) })
await vi.waitFor(async () => { expect(await recorderDirs()).toEqual([]) })
})
it('drops a disposed session’s recorder and starts afresh on its next turn', async () => {
@@ -2343,6 +2343,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: 'SidebarRightTabInjected',
declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted',
occupants: [
'client-ui-deliverables ReviewTab',
'client-ui-sidebar-documentpreview TextPreview',
'client-ui-sidebar-files FilesBody',
'client-ui-sidebar-right GuideBody',