diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 2540a8fbaf..a682a4eae2 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/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 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 637dcfcf48..0957adb0ee 100644 --- a/docs/persistence-catalog.md +++ b/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 | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index e8af53ae63..45e7155b0d 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1258,7 +1258,7 @@ export type SessionEvent = { '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) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index c62914b099..42f29faa41 100644 --- a/docs/persistence-schema.json +++ b/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" ] }, { diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts index 29b0da9faf..1e4ec7dddd 100644 --- a/packages/client/ui-deliverables/src/changes.ts +++ b/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}` } diff --git a/packages/client/ui-deliverables/src/client/changes-diff.ts b/packages/client/ui-deliverables/src/client/changes-diff.ts index c9fb23b0fd..2a22bf6d02 100644 --- a/packages/client/ui-deliverables/src/client/changes-diff.ts +++ b/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>({}) - 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>() +/** One browser plugin's comparison reads; a failed read is the one state a later request replaces. */ +export class ChangesDiffStore extends HostReadStore { + 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 { - 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 { - this.lifetime.abort() - await Promise.all(this.pending) - } - - private async read(url: string, signal: AbortSignal): Promise { - 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 { + return this.loadUrl(changesDiffUrl(sessionId, seq, index)) } } diff --git a/packages/client/ui-deliverables/src/client/changes-summary.ts b/packages/client/ui-deliverables/src/client/changes-summary.ts index 5dd4b37be8..49d728f67f 100644 --- a/packages/client/ui-deliverables/src/client/changes-summary.ts +++ b/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>({}) - 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>() +/** One browser plugin's summary reads; a summary or a missing answer is kept until the connection is replaced. */ +export class ChangesSummaryStore extends HostReadStore { + 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 { - 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 { - this.lifetime.abort() - await Promise.all(this.pending) - } - - private async read(url: string, signal: AbortSignal): Promise { - 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 { + return this.loadUrl(changesSummaryUrl(sessionId, seq)) } } diff --git a/packages/client/ui-deliverables/src/client/host-read-store.ts b/packages/client/ui-deliverables/src/client/host-read-store.ts new file mode 100644 index 0000000000..120504ee2e --- /dev/null +++ b/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 { + /** 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 +} + +/** One browser plugin's reads of one record kind. */ +export class HostReadStore { + /** Record URLs key the state across Sessions and turns. */ + readonly state = createSnapshotStore>({}) + 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>() + + constructor(private readonly policy: HostReadPolicy) {} + + /** + * 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 { + 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 { + this.lifetime.abort() + await Promise.all(this.pending) + } + + private async read(url: string, signal: AbortSignal): Promise { + 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 }) + } +} diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 233f58538e..56b88f0651 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/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 { +/** 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 { + 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 { - 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() diff --git a/packages/deliverables/workspace-changes/src/paths.ts b/packages/deliverables/workspace-changes/src/paths.ts index 25cc47ed70..306f5bbe61 100644 --- a/packages/deliverables/workspace-changes/src/paths.ts +++ b/packages/deliverables/workspace-changes/src/paths.ts @@ -53,10 +53,10 @@ export async function canonicalPath(path: string): Promise { 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 } diff --git a/packages/deliverables/workspace-changes/tests/paths.spec.ts b/packages/deliverables/workspace-changes/tests/paths.spec.ts index 699d8b90dd..8d0beee797 100644 --- a/packages/deliverables/workspace-changes/tests/paths.spec.ts +++ b/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')) }) }) diff --git a/packages/deliverables/workspace-changes/tests/plugin.spec.ts b/packages/deliverables/workspace-changes/tests/plugin.spec.ts index 35c345e32e..b304c5a12a 100644 --- a/packages/deliverables/workspace-changes/tests/plugin.spec.ts +++ b/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 => (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 () => { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e080b567cd..8752a7de89 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/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',