directory-picker.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. /**
  2. * Host directory-picking Remote owner: capability gating, cancellation, and the
  3. * stable wire failure vocabulary over the `ctx.directoryPicker` seam.
  4. */
  5. import { Context } from '@deepseek-ai/cordis'
  6. import { z } from 'zod'
  7. import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
  8. import type {
  9. DirectoryPickerCapabilities, DirectoryPickerErrorCode,
  10. } from '@deepseek-ai/dsh-host-directory-picker'
  11. // The seam owns the listing declaration; the generator requires the reference
  12. // site to name that package rather than this package's re-export of it.
  13. import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types'
  14. import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
  15. import type { RemoteErrorCode } from '@deepseek-ai/dsh-typert-protocol'
  16. const createDirectoryRequestSchema = z.object({
  17. path: z.string(),
  18. name: z.string(),
  19. }).refine(
  20. request => request.name.trim() !== '' && request.name !== '.' && request.name !== '..'
  21. && !/[/\\]/.test(request.name),
  22. { message: 'host.createDirectory requires a single non-blank path segment name' },
  23. )
  24. declare module '@deepseek-ai/cordis' {
  25. interface Context {
  26. /** Host directory-picking Remote namespace owner. */
  27. directoryPickerController: DirectoryPickerController
  28. }
  29. }
  30. /**
  31. * Host service backing the generated `ctx.remote.directoryPicker` namespace. The
  32. * seam it exports is abstract and therefore never a Loader entry of its own, so
  33. * this controller carries the wire verbs: one composed backend serves either the
  34. * native chooser or the browse primitives, and a verb the composition cannot
  35. * serve is refused rather than approximated.
  36. */
  37. export class DirectoryPickerController extends TypertRemoteService {
  38. static inject = ['directoryPicker']
  39. /** @param ctx - Host context carrying the composed directory-picking backend. */
  40. constructor(ctx: Context) {
  41. super(ctx, 'directoryPickerController', { namespace: 'directoryPicker' })
  42. }
  43. /**
  44. * Open the host's OS chooser for a Remote caller.
  45. * @param signal - caller lifetime; abort terminates the chooser.
  46. * @returns the chosen absolute path, or null when the operator cancels.
  47. */
  48. @Remote('pick')
  49. async pick(signal: AbortSignal): Promise<string | null> {
  50. const capability = this.requireCapability('native', 'pick')
  51. try {
  52. return await capability.pick(signal)
  53. } catch (error: unknown) {
  54. throw cancellableFailure(error, signal, 'directory picker was aborted', 'directory picker failed')
  55. }
  56. }
  57. /**
  58. * List one directory level for a Remote caller's in-app browser.
  59. * @param path - absolute directory to list; absent lists the home directory.
  60. * @param signal - caller lifetime; abort stops the backend's scan instead of
  61. * letting it outlive a disconnected caller.
  62. * @returns the level's listing with its ancestry.
  63. */
  64. @Remote('list')
  65. async list(path: string | undefined, signal: AbortSignal): Promise<DirectoryListing> {
  66. const capability = this.requireCapability('browse', 'list')
  67. try {
  68. return await capability.list(path, signal)
  69. } catch (error: unknown) {
  70. throw cancellableFailure(error, signal, 'directory listing was aborted')
  71. }
  72. }
  73. /**
  74. * Create one child directory for a Remote caller's in-app browser.
  75. * @param path - absolute existing parent directory.
  76. * @param name - single non-blank path segment.
  77. * @returns the created directory's absolute path.
  78. */
  79. @Remote('createDirectory')
  80. async createDirectory(path: string, name: string): Promise<string> {
  81. const request = createDirectoryRequestSchema.safeParse({ path, name })
  82. if (!request.success) {
  83. throw new RemoteError(
  84. 'gateway/bad-request',
  85. 'invalid payload for host.createDirectory',
  86. { issues: request.error.issues },
  87. )
  88. }
  89. const capability = this.requireCapability('browse', 'createDirectory')
  90. try {
  91. return await capability.createDirectory(request.data.path, request.data.name)
  92. } catch (error: unknown) {
  93. throw browseFailure(error)
  94. }
  95. }
  96. /** Resolve the capability one wire verb needs, or refuse with the kind this backend serves. */
  97. private requireCapability<Kind extends keyof DirectoryPickerCapabilities>(
  98. kind: Kind,
  99. method: string,
  100. ): DirectoryPickerCapabilities[Kind] {
  101. const capability = this.ctx.directoryPicker.capability()
  102. if (capability.kind !== kind) {
  103. throw new RemoteError(
  104. 'directory-picker/unavailable',
  105. `directoryPicker.${method} needs the ${kind} capability; the composed picker serves "${capability.kind}"`,
  106. { capability: capability.kind },
  107. )
  108. }
  109. return capability as DirectoryPickerCapabilities[Kind]
  110. }
  111. }
  112. /**
  113. * Wire code answered for each seam browse failure. The seam's closed codes are
  114. * its own local vocabulary, so this controller owns the projection onto the
  115. * `directory-picker/*` codes a Remote caller discriminates on.
  116. */
  117. const BROWSE_FAILURE_CODES = {
  118. 'directory-unreadable': 'directory-picker/unreadable',
  119. 'directory-exists': 'directory-picker/exists',
  120. 'directory-create-failed': 'directory-picker/create-failed',
  121. } as const satisfies Record<DirectoryPickerErrorCode, RemoteErrorCode>
  122. /**
  123. * Classify a browse-primitive rejection: the seam's own closed codes carry the
  124. * path they are about, and anything else stays an infrastructure failure.
  125. * @param error - the primitive's rejection.
  126. * @returns the failure to throw across the Remote boundary.
  127. */
  128. function browseFailure(error: unknown): RemoteError {
  129. if (error instanceof DirectoryPickerError) {
  130. return new RemoteError(
  131. BROWSE_FAILURE_CODES[error.code],
  132. error.message,
  133. { path: error.path },
  134. { cause: error },
  135. )
  136. }
  137. return new RemoteError('gateway/internal', errorMessage(error), {}, { cause: error })
  138. }
  139. /**
  140. * Classify a cancellable primitive's rejection. An abort is the caller's own
  141. * timeout or disconnect, not a backend failure, so it answers `gateway/cancelled`
  142. * before the business classification runs.
  143. * @param error - the primitive's rejection.
  144. * @param signal - the caller lifetime the primitive ran under.
  145. * @param cancelled - operator-facing text for the abort outcome.
  146. * @param failed - prefix for a non-seam failure, when the verb has no closed codes.
  147. * @returns the failure to throw across the Remote boundary.
  148. */
  149. function cancellableFailure(
  150. error: unknown,
  151. signal: AbortSignal,
  152. cancelled: string,
  153. failed?: string,
  154. ): RemoteError {
  155. if (signal.aborted) return new RemoteError('gateway/cancelled', cancelled, {}, { cause: error })
  156. if (failed === undefined) return browseFailure(error)
  157. return new RemoteError('gateway/internal', `${failed}: ${errorMessage(error)}`, {}, { cause: error })
  158. }
  159. function errorMessage(error: unknown): string {
  160. return error instanceof Error ? error.message : String(error)
  161. }