commands.ts 7.2 KB

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