Explorar el Código

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.
creatixchu hace 2 semanas
padre
commit
974d0271c0

+ 2 - 2
docs/persistence-catalog.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 docs/persistence-catalog.md
-persistence-catalog.md: 637dcfcf48d2699b0072d087c7a765fe900a7684
-persistence-catalog.zh.md: e8af53ae630ac7b061e9d158eef8f26d2a5fdde4
+persistence-catalog.md: 0957adb0ee15587eedff2b38742616bc4c8decba
+persistence-catalog.zh.md: 45e7155b0ddf8d72ac132e0ac79ea31c28acea3c

+ 2 - 2
docs/persistence-catalog.md

@@ -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
docs/persistence-catalog.zh.md

@@ -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
docs/persistence-schema.json

@@ -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"
       ]
     },
     {

+ 2 - 1
packages/client/ui-deliverables/src/changes.ts

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

+ 19 - 51
packages/client/ui-deliverables/src/client/changes-diff.ts

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

+ 17 - 48
packages/client/ui-deliverables/src/client/changes-summary.ts

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

+ 81 - 0
packages/client/ui-deliverables/src/client/host-read-store.ts

@@ -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 })
+  }
+}

+ 16 - 10
packages/client/ui-deliverables/src/present-open.ts

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

+ 3 - 3
packages/deliverables/workspace-changes/src/paths.ts

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

+ 2 - 0
packages/deliverables/workspace-changes/tests/paths.spec.ts

@@ -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'))
   })
 })
 

+ 14 - 7
packages/deliverables/workspace-changes/tests/plugin.spec.ts

@@ -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 () => {

+ 1 - 0
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

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