rpc.schema.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. /**
  2. * Message-layer zod schemas for API Proxy unary calls and Host pushes. The
  3. * payload slot is unknown in the full-form schemas — business payloads get a
  4. * second parse dispatched by method (two-level parse discipline). Brand cast
  5. * 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, RpcError, RpcId, 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 schema validation (the only cast point in this
  24. * file). No min-length: the id is an opaque echo token, and rejecting values
  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('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }),
  35. z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }),
  36. z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
  37. z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
  38. z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
  39. z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
  40. z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
  41. z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
  42. z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
  43. z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
  44. z.object({ code: z.literal('settings-rejected'), message: z.string(), details: z.object({ ns: z.string() }) }),
  45. z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
  46. z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
  47. z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),
  48. z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
  49. z.object({ code: z.literal('subagent-not-found'), message: z.string(), details: z.object({ parentSessionId: z.string(), childSessionId: z.string() }) }),
  50. z.object({ code: z.literal('subagent-catalog-diagnostic'), message: z.string(), details: z.object({
  51. parentSessionId: z.string(),
  52. childSessionId: z.string(),
  53. reason: z.union([z.literal('corrupt'), z.literal('unsupported'), z.literal('unavailable')]),
  54. }) }),
  55. z.object({ code: z.literal('subagent-not-resumable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
  56. z.object({ code: z.literal('subagent-unauthorized'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
  57. z.object({ code: z.literal('subagent-delivery-unavailable'), message: z.string(), details: z.object({ childSessionId: z.string() }) }),
  58. z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
  59. ]) as unknown as z.ZodType<RpcError>
  60. /**
  61. * Business success/failure result schema (generic, reusable).
  62. * @param value - Schema for the business value.
  63. * @returns Schema for RpcResult<T>.
  64. */
  65. export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.ZodType, z.ZodType]> {
  66. return z.union([
  67. z.object({ ok: z.literal(true), value }),
  68. z.object({ ok: z.literal(false), error: rpcErrorSchema }),
  69. ])
  70. }
  71. // ---- Wire envelope schemas (payload/result.value stay wide for the second business parse) ----
  72. // The wide value slot is optional: a void business result serializes with no
  73. // `value` field at all. Each endpoint's own second parse still requires its
  74. // declared value, so absence never passes for a method that returns data.
  75. /** ClientRequest full form (payload stays wide — the business layer runs the second parse). */
  76. export const clientRequestSchema = z.object({
  77. type: z.literal('client-request'),
  78. rpcId: rpcIdSchema,
  79. method: z.string(),
  80. payload: z.unknown(),
  81. }) as unknown as z.ZodType<ClientRequest>
  82. /** ServerResponse full form (result.value stays wide). */
  83. export const serverResponseSchema = z.object({
  84. type: z.literal('server-response'),
  85. rpcId: rpcIdSchema,
  86. result: rpcResultSchema(z.unknown().optional()),
  87. }) as unknown as z.ZodType<ServerResponse>
  88. /** Wire full-form union (discriminated by type). */
  89. export const rpcMessageSchema = z.discriminatedUnion('type', [
  90. clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
  91. serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
  92. ])