commands.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. /** Workspace command implementation and stable Remote failure mapping. */
  2. import type { Context } from '@deepseek-ai/cordis'
  3. import type { Workspace } from '@deepseek-ai/dsh-workspace'
  4. import {
  5. WorkspaceId,
  6. WorkspaceMoveInvalidError,
  7. WorkspaceOrderInvalidError,
  8. WorkspaceUnknownSessionError,
  9. } from '@deepseek-ai/dsh-workspace'
  10. import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
  11. import { workspaceView } from './feed.ts'
  12. import type {
  13. WorkspaceArchiveSessionRequest,
  14. WorkspaceArchiveValue,
  15. WorkspaceCreateRequest,
  16. WorkspaceCreateValue,
  17. WorkspaceDeleteRequest,
  18. WorkspaceDeleteValue,
  19. WorkspaceInsertBeforeRequest,
  20. WorkspaceInsertSessionBeforeRequest,
  21. WorkspaceOrderValue,
  22. WorkspaceRenameRequest,
  23. WorkspaceValue,
  24. } from './types.ts'
  25. /** Implements Workspace mutations against the authoritative registry. */
  26. export class WorkspaceCommands {
  27. private operationTail = Promise.resolve()
  28. /** @param ctx - Host context containing the Workspace registry. */
  29. constructor(private readonly ctx: Context) {}
  30. /**
  31. * Create or resolve one Workspace over an existing directory.
  32. * @param request - directory path to register.
  33. * @returns the Workspace and whether this call created it.
  34. */
  35. create(request: WorkspaceCreateRequest): Promise<WorkspaceCreateValue> {
  36. return this.enqueue(async () => {
  37. try {
  38. const existing = await this.ctx.workspaceRegistry.resolveByPath(request.path)
  39. if (existing !== undefined) {
  40. return { workspace: workspaceView(existing), created: false }
  41. }
  42. const workspace = await this.ctx.workspaceRegistry.create(request.path)
  43. return { workspace: workspaceView(workspace), created: true }
  44. } catch (error) {
  45. if (remoteErrorOf(error) !== undefined) throw error
  46. throw new RemoteError(
  47. 'workspace/invalid-path',
  48. `cannot create a Workspace at "${request.path}": ${errorMessage(error)}`,
  49. { path: request.path },
  50. { cause: error },
  51. )
  52. }
  53. })
  54. }
  55. /**
  56. * Rename one Workspace after serializing title ownership checks.
  57. * @param request - Workspace identity and proposed title.
  58. * @returns the updated Workspace projection.
  59. */
  60. rename(request: WorkspaceRenameRequest): Promise<WorkspaceValue> {
  61. const title = request.title.trim()
  62. if (title === '') {
  63. return Promise.reject(new RemoteError('gateway/bad-request', 'Workspace rename requires a non-blank title', {}))
  64. }
  65. return this.enqueue(async () => {
  66. const workspace = this.requireWorkspace(request.workspaceId)
  67. if (title !== workspace.title) {
  68. if (this.ctx.workspaceRegistry.list().some(candidate =>
  69. candidate.id !== workspace.id && candidate.title === title)) {
  70. throw new RemoteError(
  71. 'workspace/name-conflict',
  72. `Workspace name '${title}' is already in use`,
  73. { name: title },
  74. )
  75. }
  76. await workspace.setTitle(title)
  77. }
  78. return { workspace: workspaceView(workspace) }
  79. })
  80. }
  81. /**
  82. * Delete one Workspace registration without deleting its directory or Sessions.
  83. * @param request - Workspace identity to remove.
  84. * @returns deletion confirmation.
  85. */
  86. delete(request: WorkspaceDeleteRequest): Promise<WorkspaceDeleteValue> {
  87. return this.enqueue(async () => {
  88. if (!await this.ctx.workspaceRegistry.delete(WorkspaceId(request.workspaceId))) {
  89. throw workspaceNotFound(request.workspaceId)
  90. }
  91. return { deleted: true }
  92. })
  93. }
  94. /**
  95. * Move one Workspace within the durable registry order.
  96. * @param request - moved Workspace and optional anchor.
  97. * @returns the complete resulting Workspace order.
  98. */
  99. async insertBefore(request: WorkspaceInsertBeforeRequest): Promise<WorkspaceOrderValue> {
  100. try {
  101. const workspaceIds = await this.ctx.workspaceRegistry.insertBefore(
  102. WorkspaceId(request.workspaceId),
  103. request.beforeWorkspaceId === undefined
  104. ? undefined
  105. : WorkspaceId(request.beforeWorkspaceId),
  106. )
  107. return { workspaceIds: [...workspaceIds] }
  108. } catch (error) {
  109. if (!(error instanceof WorkspaceOrderInvalidError)) throw error
  110. throw workspaceNotFound(error.workspaceId)
  111. }
  112. }
  113. /**
  114. * Move one accounted Session within a Workspace's manual order.
  115. * @param request - Workspace, Session, and optional anchor identities.
  116. * @returns the updated Workspace projection.
  117. */
  118. async insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise<WorkspaceValue> {
  119. const workspace = this.requireWorkspace(request.workspaceId)
  120. try {
  121. await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId)
  122. } catch (error) {
  123. if (!(error instanceof WorkspaceMoveInvalidError)) throw error
  124. throw new RemoteError(
  125. 'workspace/move-invalid',
  126. error.message,
  127. {
  128. workspaceId: request.workspaceId,
  129. sessionId: request.sessionId,
  130. ...request.beforeSessionId === undefined
  131. ? {}
  132. : { beforeSessionId: request.beforeSessionId },
  133. },
  134. { cause: error },
  135. )
  136. }
  137. return { workspace: workspaceView(workspace) }
  138. }
  139. /**
  140. * Add one known Session to the registry-global archive set.
  141. * @param request - Session identity to archive.
  142. * @returns the complete resulting archive set.
  143. */
  144. async archiveSession(request: WorkspaceArchiveSessionRequest): Promise<WorkspaceArchiveValue> {
  145. try {
  146. await this.ctx.workspaceRegistry.archiveSession(request.sessionId)
  147. } catch (error) {
  148. if (!(error instanceof WorkspaceUnknownSessionError)) throw error
  149. throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId }, { cause: error })
  150. }
  151. return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] }
  152. }
  153. private requireWorkspace(workspaceId: WorkspaceId): Workspace {
  154. const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId))
  155. if (workspace === undefined) throw workspaceNotFound(workspaceId)
  156. return workspace
  157. }
  158. private enqueue<T>(operation: () => Promise<T>): Promise<T> {
  159. const result = this.operationTail.then(operation)
  160. this.operationTail = result.then(() => undefined, () => undefined)
  161. return result
  162. }
  163. }
  164. function workspaceNotFound(workspaceId: WorkspaceId): RemoteError<'workspace/not-found'> {
  165. return new RemoteError(
  166. 'workspace/not-found',
  167. `Workspace "${workspaceId}" not found`,
  168. { workspaceId },
  169. )
  170. }
  171. function errorMessage(error: unknown): string {
  172. return error instanceof Error ? error.message : String(error)
  173. }