rpc.schema.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. /**
  2. * Message-layer zod schemas: the four wire full forms + error body +
  3. * carrier receipt. The payload slot is unknown in the full-form schemas — business payloads
  4. * get a second parse dispatched by method (two-level parse discipline).
  5. * Brand cast point: rpcIdSchema, and only there.
  6. */
  7. import { z } from 'zod'
  8. import type { z as zCore } from 'zod'
  9. type ZodIssue = zCore.core.$ZodIssue
  10. import type { ClientRequest, ClientResponse, RpcError, RpcId, RpcReceipt, ServerRequest, ServerResponse } from './rpc.ts'
  11. /**
  12. * Wire widening of a contract type: widens every property (deeply) to `original | undefined`.
  13. * The repo enables exactOptionalPropertyTypes while zod `.optional()` outputs `T | undefined`,
  14. * so `satisfies z.ZodType<ContractType>` is unusable across the board; anchoring is always
  15. * written `satisfies z.ZodType<Wire<ContractType>>` — the widening only adds undefined, so
  16. * missing fields / wrong types still fail to compile. On the JSON wire, "absent" and
  17. * "value undefined" serialize identically, so the widening loses no validation semantics.
  18. */
  19. export type Wire<T> = T extends readonly (infer E)[] ? Wire<E>[]
  20. : T extends object ? { [K in keyof T]: Wire<T[K]> | undefined }
  21. : T
  22. /**
  23. * RpcId: one brand cast after shape validation (the only cast point in this
  24. * file). No min-length: the id is an opaque echo token, and rejecting shapes
  25. * here would only turn a correlatable error report into a client-side parse
  26. * failure (the handler substitutes a sentinel when a request's id is unreadable).
  27. */
  28. export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
  29. /** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
  30. export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
  31. z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
  32. z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
  33. z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
  34. z.object({ code: z.literal('model-unavailable'), message: z.string(), details: z.object({ provider: z.string(), model: z.string() }) }),
  35. z.object({ code: z.literal('session-conflict'), message: z.string(), details: z.object({ sessionId: z.string(), requestedCwd: z.string(), existingCwd: z.string().optional() }) }),
  36. z.object({ code: z.literal('workspace-attach-failed'), message: z.string(), details: z.object({ sessionId: z.string(), workspaceId: z.string() }) }),
  37. z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
  38. z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
  39. z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
  40. z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
  41. z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }),
  42. z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
  43. z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
  44. z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
  45. z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
  46. z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }),
  47. z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }),
  48. z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
  49. z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),
  50. z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
  51. z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
  52. z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
  53. z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
  54. z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),
  55. z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
  56. z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
  57. z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
  58. z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }),
  59. z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({
  60. parentSessionId: z.string(),
  61. childSessionId: z.string(),
  62. reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
  63. }) }),
  64. z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
  65. z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
  66. z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
  67. z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
  68. ]) as unknown as z.ZodType<RpcError>
  69. /**
  70. * Business success/failure result schema (generic, reusable).
  71. * @param value - Schema for the business value.
  72. * @returns Schema for RpcResult<T>.
  73. */
  74. export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.ZodType, z.ZodType]> {
  75. return z.union([
  76. z.object({ ok: z.literal(true), value }),
  77. z.object({ ok: z.literal(false), error: rpcErrorSchema }),
  78. ])
  79. }
  80. // ---- The four wire full-form schemas (payload/result.value slots stay wide — business layer does the second parse) ----
  81. /** ClientRequest full form (payload stays wide — the business layer runs the second parse). */
  82. export const clientRequestSchema = z.object({
  83. type: z.literal('client-request'),
  84. rpcId: rpcIdSchema,
  85. method: z.string(),
  86. payload: z.unknown(),
  87. }) as unknown as z.ZodType<ClientRequest>
  88. /** ServerResponse full form (result.value stays wide). */
  89. export const serverResponseSchema = z.object({
  90. type: z.literal('server-response'),
  91. rpcId: rpcIdSchema,
  92. result: rpcResultSchema(z.unknown()),
  93. }) as unknown as z.ZodType<ServerResponse>
  94. /** ServerRequest full form (payload stays wide). */
  95. export const serverRequestSchema = z.object({
  96. type: z.literal('server-request'),
  97. rpcId: rpcIdSchema,
  98. method: z.string(),
  99. payload: z.unknown(),
  100. }) as unknown as z.ZodType<ServerRequest>
  101. /** ClientResponse full form (result.value stays wide). */
  102. export const clientResponseSchema = z.object({
  103. type: z.literal('client-response'),
  104. rpcId: rpcIdSchema,
  105. result: rpcResultSchema(z.unknown()),
  106. }) as unknown as z.ZodType<ClientResponse>
  107. /** Wire full-form union (discriminated by type). */
  108. export const rpcMessageSchema = z.discriminatedUnion('type', [
  109. clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
  110. serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
  111. serverRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
  112. clientResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
  113. ])
  114. /** Carrier receipt schema. */
  115. export const rpcReceiptSchema = z.union([
  116. z.object({ accepted: z.literal(true) }),
  117. z.object({ accepted: z.literal(false), reason: z.union([z.literal('not-pending'), z.literal('bad-response')]) }),
  118. ]) satisfies z.ZodType<Wire<RpcReceipt>>