directory-picker.ts 6.6 KB

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