Files
deepseek-harness/packages/api/workspace-files/src/client/change-feed.ts
T

294 lines
10 KiB
TypeScript

/**
* One Host `changes` subscription per Session and requested path, shared by
* that target's open resources.
*
* The Host reports invalidations from filesystem watches and instrumented writes.
* The feed opens the target stream when the first follower
* arrives, hands each frame to the followers of its path, and disposes the
* stream when the last follower leaves. A follower buffers target changes
* until `stat` supplies its Host absolute path, then filters queued and live
* frames by that path, with `\\` normalized to `/`.
*/
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { WorkspaceFileChange, WorkspaceFileWatchFrame } from '../types.ts'
import type { SupervisedStream, WorkspaceFilesRemote } from './remote.ts'
import type { WorkspaceFileEdit, WorkspaceFileNotice } from './types.ts'
/**
* The follower key of one absolute path.
* @param path - an absolute path from a Host stat or change frame.
* @returns the path with `\\` normalized to `/`.
*/
function keyOf(path: string): string {
return path.replace(/\\/g, '/')
}
/** Notices of one follower, delivered in order and pulled by its consumer. */
class Follower implements AsyncIterable<WorkspaceFileNotice> {
private readonly pending: Array<{ readonly key: string; readonly notice: WorkspaceFileNotice }> = []
private readonly started = Promise.withResolvers<boolean>()
private wake: (() => void) | undefined
private ended = false
private hostKey: string | undefined
/**
* Resolves true after the Host acknowledges its subscription, or false if
* this follower ends before acknowledgement.
*/
readonly ready = this.started.promise
/**
* @param leave - unregisters this follower and its abort listener.
*/
constructor(private readonly leave: () => void) {}
/**
* Select the Host path for queued and future changes.
* @param absolutePath - the successful stat's absolute path.
*/
bind(absolutePath: string): void {
this.hostKey = keyOf(absolutePath)
}
/** The Host acknowledged an active subscription and resolved workspace root. */
start(): void {
this.started.resolve(true)
}
/**
* Queue one notice.
* @param notice - what the consumer receives next.
* @param key - normalized Host path for the change.
*/
push(notice: WorkspaceFileNotice, key: string): void {
this.pending.push({ key, notice })
this.wake?.()
}
/** Deliver what is queued, then finish. */
end(): void {
this.ended = true
this.started.resolve(false)
this.wake?.()
}
/** Unregister even when the consumer has not started pulling notices. */
dispose(): void {
this.leave()
}
/** @inheritdoc */
async *[Symbol.asyncIterator](): AsyncIterator<WorkspaceFileNotice> {
try {
while (true) {
const next = this.pending.shift()
if (next !== undefined) {
if (next.notice.kind === 'refresh' || this.hostKey === undefined || next.key === this.hostKey) yield next.notice
continue
}
if (this.ended) return
await new Promise<void>((resolve) => { this.wake = resolve })
this.wake = undefined
}
} finally {
this.dispose()
}
}
}
/** The stream and followers of one Session and requested path. */
class SessionFeed {
private readonly followers = new Set<Follower>()
private readonly stream: SupervisedStream<WorkspaceFileWatchFrame>
private closed = false
private started = false
private acknowledged = false
/**
* @param remote - the Remote face carrying `workspaceFiles.changes`.
* @param sessionId - the Session whose filesystem this feed observes.
* @param path - target path submitted to the Host.
* @param after - the previous feed of this target still closing, if any; the stream opens once it has settled.
* @param onClose - called once when the stream is gone, whatever the cause, with the dispose that is closing it.
*/
constructor(
remote: WorkspaceFilesRemote,
sessionId: SessionId,
path: string,
after: Promise<void> | undefined,
private readonly onClose: (closed: Promise<void>) => void,
) {
this.stream = remote.$stream<WorkspaceFileWatchFrame>({
name: `workspace file changes of ${sessionId}`,
// A predecessor still closing finishes first, so one target never has
// two Host streams open at once.
open: (signal) => {
this.started = false
return openAfter(after, () => remote.workspaceFiles.changes(sessionId, path, signal))
},
// A normal end means the Host closed the target's feed.
ended: () => new Error(`workspace file changes of ${sessionId} ended`),
})
void this.pump()
}
/**
* Register one resource address before its Host path is known.
* @param follower - receives changes and binds its path after stat.
*/
add(follower: Follower): void {
this.followers.add(follower)
if (this.started) follower.start()
}
/**
* Unregister one follower; the last one leaving disposes the stream.
* @param follower - the follower to drop.
*/
remove(follower: Follower): void {
this.followers.delete(follower)
if (this.followers.size === 0) this.close()
}
private async pump(): Promise<void> {
try {
for await (const item of this.stream) {
const frame = item.value
switch (frame.kind) {
case 'ready':
item.accept()
this.started = true
for (const follower of this.followers) {
follower.start()
if (this.acknowledged) follower.push({ kind: 'refresh' }, '')
}
this.acknowledged = true
break
case 'change': {
const key = keyOf(frame.change.absolutePath)
const notice = editOf(frame.change)
for (const follower of this.followers) follower.push(notice, key)
break
}
default:
assertNever(frame)
}
}
} catch {
// A terminal stream failure or the Host's end: followers end quietly
// below, and the metadata they hold stays the last known.
} finally {
this.close()
}
}
private close(): void {
if (this.closed) return
this.closed = true
const closed = this.stream.dispose()
for (const follower of this.followers) follower.end()
this.followers.clear()
this.onClose(closed)
}
}
/**
* Open a Host stream once a predecessor has finished closing.
* @param after - the predecessor's dispose, or nothing to wait for.
* @param open - opens the stream.
* @returns the stream's items.
*/
async function* openAfter<T>(after: Promise<void> | undefined, open: () => AsyncIterable<T>): AsyncIterable<T> {
await after
yield* open()
}
/**
* The write one Host frame reports.
* @param frame - the Host frame.
* @returns the edit notice followers receive.
*/
function editOf(frame: WorkspaceFileChange): WorkspaceFileEdit {
return 'absent' in frame ? { kind: 'absent' } : { kind: 'changed', version: frame.version }
}
function assertNever(frame: never): never {
throw new Error(`Unexpected workspace file watch frame: ${JSON.stringify(frame)}`)
}
/**
* Per-target sharing of the Host's workspace file change streams.
*
* Owned by the provider; one instance serves every session of the Client.
*/
export class ChangeFeed {
/** Live feeds only: a feed removes itself when its stream closes. */
private readonly sessions = new Map<string, SessionFeed>()
/** Streams still closing, by Session and path; a successor waits for its predecessor. */
private readonly closing = new Map<string, Promise<void>>()
/**
* @param remote - the Remote face carrying `$stream` and `workspaceFiles.changes`.
*/
constructor(private readonly remote: WorkspaceFilesRemote) {}
/**
* Follow one resource in one session before its Host path is known.
*
* The follower is registered on call, not on first pull. Changes delivered
* to this Client are queued while stat is pending. The first follower starts
* target's local `changes` call. The iterable ends
* when `signal` aborts or when the target stream is gone; ending it early
* (`break`, `return`) unregisters the follower as well, and the last follower
* of a target disposes its stream. Await a true `ready` result before stat
* so the Host subscription is active, then bind each stat's absolute path. Until binding,
* any target invalidation can trigger a retry; after binding, only matching queued
* and live changes pass.
* @param sessionId - the Session providing the file's read authority.
* @param path - requested file path; followers share a stream only for the same Session and path.
* @param signal - ends the follow.
* @returns a single-consumer subscription with Host-path binding and explicit disposal.
*/
follow(sessionId: SessionId, path: string, signal: AbortSignal): Follower {
const feed = signal.aborted ? undefined : this.feedOf(sessionId, path)
const leave = (): void => {
signal.removeEventListener('abort', leave)
follower.end()
feed?.remove(follower)
}
const follower = new Follower(leave)
if (feed === undefined) {
follower.end()
} else {
feed.add(follower)
signal.addEventListener('abort', leave, { once: true })
}
return follower
}
/**
* Wait for every stream that is still closing, so an owner tearing down
* leaves no Host stream behind.
* @returns resolves once no stream of this feed is closing.
*/
async settle(): Promise<void> {
await Promise.all(this.closing.values())
}
private feedOf(sessionId: SessionId, path: string): SessionFeed {
const key = JSON.stringify([sessionId, path])
const existing = this.sessions.get(key)
if (existing !== undefined) return existing
const feed = new SessionFeed(this.remote, sessionId, path, this.closing.get(key), (closed) => {
this.sessions.delete(key)
// A dispose that rejects is still a settled close: nothing remains to wait for.
const tracked: Promise<void> = closed.then(() => undefined, () => undefined).then(() => {
if (this.closing.get(key) === tracked) this.closing.delete(key)
})
this.closing.set(key, tracked)
})
this.sessions.set(key, feed)
return feed
}
}