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

155 lines
6.3 KiB
TypeScript

/**
* The `file` protocol's provider: a workspace file's metadata as a stream of
* `RemoteResult` frames.
*
* An address names the file in one of two scopes. A `session` address,
* `dsh-resource://file/session/<sessionId>/<path>`, carries an absolute path or one
* relative to that Session's workspace root: the Host receives the path as-is and
* resolves it against the root it holds. Only the Host's `stat.absolutePath`
* selects the change-feed key; no Client Session summary is needed.
* An `absolute` address, `dsh-resource://file/absolute/<path>`, carries no
* Session and cannot authorize a Host call. An address neither scope
* resolves yields one failure frame — `workspace-file/unsupported-address` for
* a string outside the grammar, `workspace-file/unknown-workspace` when the
* address carries no Session — and ends.
*
* The first frame is the file's `stat`; subsequent invalidations run `stat`
* again unless their version or absence is already known. Reconnection also
* restats the file. Failures travel as `ok: false` frames, never as thrown errors: the
* Remote face does not reject, and anything thrown inside the stream is a
* programming error the resource model lets surface. A failed stat does not end
* the stream: the next write stats again. One {@link ChangeFeed}
* serves every open file of the Client.
*/
import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
import type { WorkspaceFileStat } from '../types.ts'
import type { ChangeFeed } from './change-feed.ts'
import type { WorkspaceFilesRemote } from './remote.ts'
/** The Session and unmodified path submitted to the Host. */
interface HostFile {
readonly sessionId: SessionId
/** The path the Host receives: absolute or workspace-relative. */
readonly path: string
}
/**
* Build the `file` provider over one Remote face and one change feed.
* @param remote - the Remote face carrying `workspaceFiles.stat`.
* @param changes - target-scoped change streams shared by file resources.
* @returns the provider to register into `ctx.resources`.
*/
export function createFileResourceProvider(
remote: WorkspaceFilesRemote,
changes: ChangeFeed,
): ResourceProvider<'file'> {
return {
protocol: 'file',
async *open(address, { signal }): AsyncIterable<RemoteResult<WorkspaceFileStat>> {
const resolved = resolve(address)
if (!resolved.ok) {
yield resolved
return
}
const { sessionId, path } = resolved.value
// Queue changes delivered to this Client while stat is pending.
const notices = changes.follow(sessionId, path, signal)
const stat = (): Promise<RemoteResult<WorkspaceFileStat>> => remote.workspaceFiles.stat(sessionId, path, signal)
// Read through a call: a plain `signal.aborted` is narrowed to `false` by
// the first check and would read as always-false after the later awaits.
const aborted = (): boolean => signal.aborted
// Undefined while the last stat failed: the follow is on the address, not
// on the file, so a write can still bring the file live.
let current: WorkspaceFileStat | undefined
try {
if (!await notices.ready) {
if (aborted()) return
const result = await stat()
if (!aborted()) yield result
return
}
if (aborted()) return
const first = await stat()
if (aborted()) return
if (first.ok) {
notices.bind(first.value.absolutePath)
current = first.value
yield { ok: true, value: current }
} else {
yield first
}
for await (const notice of notices) {
if (aborted()) return
if (current === undefined) {
// Still gone: nothing new to report.
if (notice.kind === 'absent') continue
} else if (notice.kind === 'changed') {
// Frames report observations: holding this version already means the
// consumer learns nothing new.
if (notice.version === current.version) continue
}
// A Host notice may mean stale content.
const again = await stat()
if (aborted()) return
if (!again.ok) {
current = undefined
yield again
continue
}
notices.bind(again.value.absolutePath)
current = again.value
yield { ok: true, value: current }
}
} finally {
notices.dispose()
}
},
}
}
/**
* Resolve one address to the Host call it stands for, or to the failure frame it earns.
* @param address - the full address, scheme included.
* @returns the Host file, or the `unsupported-address` / `unknown-workspace` failure.
*/
function resolve(address: string): RemoteResult<HostFile> {
const parsed = parseFileAddress(address)
if (parsed === undefined) return { ok: false, error: unsupportedAddress(address) }
if (parsed.scope === 'session') {
// The address is a string boundary: its id segment is the Session id it names.
const sessionId = parsed.sessionId as SessionId
return { ok: true, value: { sessionId, path: parsed.path } }
}
return { ok: false, error: unknownWorkspace(address) }
}
/**
* The failure frame's error for an address this provider does not serve.
* @param address - the offending address.
* @returns the typed error.
*/
function unsupportedAddress(address: string): RemoteError<'workspace-file/unsupported-address'> {
return new RemoteError(
'workspace-file/unsupported-address',
`${address} is not a dsh-resource://file/session/<sessionId>/<path> or dsh-resource://file/absolute/<path> address`,
{ address },
)
}
/**
* The failure frame's error for an absolute address with no Session.
* @param address - the offending address.
* @returns the typed error.
*/
function unknownWorkspace(address: string): RemoteError<'workspace-file/unknown-workspace'> {
return new RemoteError(
'workspace-file/unknown-workspace',
`${address} requires a dsh-resource://file/session/<sessionId>/<path> address`,
{ address },
)
}