| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | import * as Schema from "effect/Schema"; |
| 4 | import * as SchemaGetter from "effect/SchemaGetter"; |
| 5 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 6 | |
| 7 | export const TrimmedString = Schema.String.pipe( |
| 8 | Schema.decodeTo( |
| 9 | Schema.String, |
| 10 | SchemaTransformation.transformEffect({ |
| 11 | decode: (value) => Effect.succeed(value.trim()), |
| 12 | encode: (value) => Effect.succeed(value.trim()), |
| 13 | }), |
| 14 | ), |
| 15 | ); |
| 16 | /** |
| 17 | * Non-empty once trimmed. A `TrimmedString` only trims when decoding or |
| 18 | * encoding, so `make` and encode see the untrimmed value: a plain |
| 19 | * `isNonEmpty` would accept `" "` there and encode it to `""`, which no |
| 20 | * longer decodes. |
| 21 | */ |
| 22 | const isNonBlank = Schema.makeFilter((value: string) => value.trim().length > 0, { |
| 23 | expected: "a non-blank string", |
| 24 | toJsonSchema: () => [{ minLength: 1 }, true], |
| 25 | arbitraryConstraint: { minLength: 1 }, |
| 26 | }); |
| 27 | export const TrimmedNonEmptyString = TrimmedString.check(isNonBlank); |
| 28 | |
| 29 | export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); |
| 30 | export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); |
| 31 | export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); |
| 32 | |
| 33 | /** |
| 34 | * Safe categories for a failed DPoP proof. These describe the class of failure |
| 35 | * without exposing proof contents or server-side authentication details. |
| 36 | */ |
| 37 | export const DpopFailureReason = Schema.Literals([ |
| 38 | "time_window", |
| 39 | "key_mismatch", |
| 40 | "request_mismatch", |
| 41 | "token_mismatch", |
| 42 | "replay", |
| 43 | "invalid_proof", |
| 44 | ]); |
| 45 | export type DpopFailureReason = typeof DpopFailureReason.Type; |
| 46 | |
| 47 | export const IsoDateTime = Schema.String; |
| 48 | export type IsoDateTime = typeof IsoDateTime.Type; |
| 49 | |
| 50 | /** |
| 51 | * Same idea for one optional value whose literal set grows over time: a |
| 52 | * member this build does not know decodes as absent rather than failing the |
| 53 | * enclosing struct. Encoding is the plain encoding. |
| 54 | */ |
| 55 | export const ForwardCompatibleOptional = <Value extends Schema.Top>(value: Value) => { |
| 56 | const decodeValue = Schema.decodeUnknownOption(value as never); |
| 57 | return Schema.optionalKey( |
| 58 | Schema.Unknown.pipe( |
| 59 | Schema.decodeTo( |
| 60 | Schema.UndefinedOr(value), |
| 61 | SchemaTransformation.transform<Value["Encoded"] | undefined, unknown>({ |
| 62 | decode: (raw) => |
| 63 | Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : undefined, |
| 64 | encode: (raw) => raw, |
| 65 | }), |
| 66 | ), |
| 67 | ), |
| 68 | ); |
| 69 | }; |
| 70 | |
| 71 | /** |
| 72 | * The nullable form, for a persisted setting whose literal set grows over |
| 73 | * time: a member this build does not know (or a missing key) decodes as null |
| 74 | * rather than failing the enclosing struct. Encoding is the plain encoding. |
| 75 | */ |
| 76 | export const ForwardCompatibleNullable = <Value extends Schema.Top>(value: Value) => { |
| 77 | const decodeValue = Schema.decodeUnknownOption(value as never); |
| 78 | return Schema.Unknown.pipe( |
| 79 | Schema.decodeTo( |
| 80 | Schema.NullOr(value), |
| 81 | SchemaTransformation.transform<Value["Encoded"] | null, unknown>({ |
| 82 | decode: (raw) => (Option.isSome(decodeValue(raw)) ? (raw as Value["Encoded"]) : null), |
| 83 | encode: (raw) => raw, |
| 84 | }), |
| 85 | ), |
| 86 | ); |
| 87 | }; |
| 88 | |
| 89 | /** |
| 90 | * A nullable setting whose null is "unset" and never crosses the wire: it |
| 91 | * decodes from a missing or unknown key and encodes back to a missing key. |
| 92 | * For a field that older clients decode as a required literal, so a null |
| 93 | * on the wire would fail their whole settings snapshot. |
| 94 | */ |
| 95 | export const OmittedWhenNull = <Value extends Schema.Top>(value: Value) => { |
| 96 | const decodeValue = Schema.decodeUnknownOption(value as never); |
| 97 | return Schema.optionalKey(Schema.Unknown).pipe( |
| 98 | Schema.decodeTo( |
| 99 | Schema.NullOr(value), |
| 100 | SchemaTransformation.transformOptional<Value["Encoded"] | null, unknown>({ |
| 101 | decode: (raw) => |
| 102 | Option.some( |
| 103 | Option.isSome(raw) && Option.isSome(decodeValue(raw.value)) |
| 104 | ? (raw.value as Value["Encoded"]) |
| 105 | : null, |
| 106 | ), |
| 107 | encode: (raw) => |
| 108 | Option.isSome(raw) && raw.value !== null ? Option.some(raw.value) : Option.none(), |
| 109 | }), |
| 110 | ), |
| 111 | ); |
| 112 | }; |
| 113 | |
| 114 | /** |
| 115 | * Wire codec for a server→client value whose shape grows over time: a union |
| 116 | * a newer server may add members to, or an array of such values. Clients keep |
| 117 | * decoding payloads from servers newer than themselves instead of failing the |
| 118 | * connection over data they could not act on anyway. |
| 119 | * |
| 120 | * Decoding runs each value through its own schema, so transformations (dates, |
| 121 | * trimming, decoding defaults) apply as usual. Only values that schema |
| 122 | * rejects are dropped, on either side. |
| 123 | * |
| 124 | * For a tagged union, prefer {@link ForwardCompatibleUnion}: it drops only |
| 125 | * values whose tag this build does not know, so a known member with a broken |
| 126 | * payload still fails loudly. |
| 127 | */ |
| 128 | export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => |
| 129 | Schema.Array( |
| 130 | Schema.UndefinedOr(element).pipe( |
| 131 | // An element this build cannot read becomes a hole, filtered out below. |
| 132 | Schema.catchDecoding(() => Effect.succeedSome(undefined)), |
| 133 | // Likewise an element that cannot be encoded is sent as a hole, so one |
| 134 | // bad element costs only itself rather than the whole payload. |
| 135 | Schema.catchEncoding(() => Effect.succeedSome(undefined)), |
| 136 | ), |
| 137 | ).pipe( |
| 138 | Schema.decodeTo( |
| 139 | Schema.Array( |
| 140 | Schema.UndefinedOr(Schema.toType(element)).pipe( |
| 141 | Schema.catchEncoding(() => Effect.succeedSome(undefined)), |
| 142 | ), |
| 143 | ).check( |
| 144 | // The holes above are an encoding detail: a decoded value has none, so |
| 145 | // `Schema.is` and `make` still reject an array that does. Aborts, so a |
| 146 | // later check on the array never sees a hole. |
| 147 | Schema.makeFilter( |
| 148 | (values) => { |
| 149 | // Every index, not `every`, which skips the holes of a sparse array. |
| 150 | for (let index = 0; index < values.length; index++) { |
| 151 | if (values[index] === undefined) return false; |
| 152 | } |
| 153 | return true; |
| 154 | }, |
| 155 | { expected: "an array without holes" }, |
| 156 | true, |
| 157 | ), |
| 158 | ), |
| 159 | SchemaTransformation.transform< |
| 160 | ReadonlyArray<Element["Type"]>, |
| 161 | ReadonlyArray<Element["Type"] | undefined> |
| 162 | >({ |
| 163 | decode: (values) => values.filter((value) => value !== undefined), |
| 164 | // An element that fails its own checks is dropped before the wire, so |
| 165 | // a wrapper that reads the encoded array as JSON values never meets |
| 166 | // the hole it left. |
| 167 | encode: (values) => values.filter((value) => value !== undefined), |
| 168 | }), |
| 169 | ), |
| 170 | ) as unknown as ForwardCompatibleArray<Element>; |
| 171 | export type ForwardCompatibleArray<Element extends Schema.Top> = Schema.Codec< |
| 172 | ReadonlyArray<Element["Type"]>, |
| 173 | ReadonlyArray<Element["Encoded"]>, |
| 174 | Element["DecodingServices"], |
| 175 | Element["EncodingServices"] |
| 176 | >; |
| 177 | |
| 178 | /** |
| 179 | * A member of a {@link ForwardCompatibleUnion} whose tag this build does not |
| 180 | * know. A class, so only the codec can make one: no decoded payload, however |
| 181 | * it is shaped, is ever mistaken for it. |
| 182 | */ |
| 183 | export class UnknownUnionMember<Tag extends string = string> { |
| 184 | readonly tag: Tag; |
| 185 | readonly value: string; |
| 186 | constructor(tag: Tag, value: string) { |
| 187 | this.tag = tag; |
| 188 | this.value = value; |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | /** |
| 193 | * Wire codec for a server→client tagged union a newer server may add members |
| 194 | * to. A value whose `tag` field holds a value this build does not know decodes |
| 195 | * to {@link UnknownUnionMember} instead of failing; a known member decodes |
| 196 | * through its own schema, so a broken known payload still fails. Callers |
| 197 | * decide what an unknown member means: skip it, or show a fallback. |
| 198 | * |
| 199 | * Unknown members are decode-only; encoding one fails. |
| 200 | */ |
| 201 | export const ForwardCompatibleUnion = < |
| 202 | const Members extends ReadonlyArray<Schema.Top & { readonly fields: object }>, |
| 203 | const Tag extends string, |
| 204 | >( |
| 205 | members: Members, |
| 206 | tag: Tag, |
| 207 | ) => { |
| 208 | const known = knownTags(members, tag); |
| 209 | const unknownMember = Schema.Struct({ |
| 210 | [tag]: Schema.String.check( |
| 211 | Schema.makeFilter( |
| 212 | (value: string) => !known.has(value) || `A known ${tag} must decode in full.`, |
| 213 | ), |
| 214 | ), |
| 215 | } as Record<Tag, Schema.String>).pipe( |
| 216 | Schema.decodeTo(Schema.instanceOf(UnknownUnionMember<Tag>), { |
| 217 | decode: SchemaGetter.transform( |
| 218 | (raw: Record<string, string>) => new UnknownUnionMember(tag, raw[tag]!), |
| 219 | ), |
| 220 | encode: SchemaGetter.forbidden(() => `Unknown ${tag} values are never sent.`), |
| 221 | }), |
| 222 | ); |
| 223 | return Schema.Union([...members, unknownMember]) as unknown as ForwardCompatibleUnion< |
| 224 | Members, |
| 225 | Tag |
| 226 | >; |
| 227 | }; |
| 228 | export type ForwardCompatibleUnion< |
| 229 | Members extends ReadonlyArray<Schema.Top>, |
| 230 | Tag extends string, |
| 231 | > = Schema.Codec< |
| 232 | Members[number]["Type"] | UnknownUnionMember<Tag>, |
| 233 | Members[number]["Encoded"], |
| 234 | Members[number]["DecodingServices"], |
| 235 | Members[number]["EncodingServices"] |
| 236 | >; |
| 237 | |
| 238 | /** |
| 239 | * Whether a raw, undecoded value carries a `tag` this build does not know |
| 240 | * among `members`. For checks outside a {@link ForwardCompatibleUnion}, such |
| 241 | * as an envelope that must skip a payload of an unknown kind. |
| 242 | */ |
| 243 | export const hasUnknownUnionTag = ( |
| 244 | members: ReadonlyArray<Schema.Top & { readonly fields: object }>, |
| 245 | tag: string, |
| 246 | ): ((value: unknown) => boolean) => { |
| 247 | const known = knownTags(members, tag); |
| 248 | return (value) => |
| 249 | typeof value === "object" && |
| 250 | value !== null && |
| 251 | tag in value && |
| 252 | !known.has((value as Record<string, unknown>)[tag] as string); |
| 253 | }; |
| 254 | |
| 255 | /** Whether a decoded {@link ForwardCompatibleUnion} value is a member this build does not know. */ |
| 256 | export const isUnknownUnionMember = <A>( |
| 257 | value: A, |
| 258 | ): value is Extract<A, UnknownUnionMember<string>> => value instanceof UnknownUnionMember; |
| 259 | |
| 260 | /** |
| 261 | * An array of a growing tagged union that drops members this build does not |
| 262 | * know. Known members decode through their own schema and still fail when |
| 263 | * broken. Encoding is the plain array encoding. |
| 264 | */ |
| 265 | export const ForwardCompatibleUnionArray = < |
| 266 | const Members extends ReadonlyArray<Schema.Top & { readonly fields: object }>, |
| 267 | const Tag extends string, |
| 268 | >( |
| 269 | members: Members, |
| 270 | tag: Tag, |
| 271 | ) => |
| 272 | Schema.Array(ForwardCompatibleUnion(members, tag)).pipe( |
| 273 | Schema.decodeTo( |
| 274 | Schema.Array(Schema.toType(Schema.Union(members))), |
| 275 | SchemaTransformation.transform< |
| 276 | ReadonlyArray<Members[number]["Type"]>, |
| 277 | ReadonlyArray<Members[number]["Type"] | UnknownUnionMember<Tag>> |
| 278 | >({ |
| 279 | decode: (values) => |
| 280 | values.filter((value) => !isUnknownUnionMember(value)) as ReadonlyArray< |
| 281 | Members[number]["Type"] |
| 282 | >, |
| 283 | encode: (values) => values, |
| 284 | }), |
| 285 | ), |
| 286 | ); |
| 287 | |
| 288 | const knownTags = ( |
| 289 | members: ReadonlyArray<Schema.Top & { readonly fields: object }>, |
| 290 | tag: string, |
| 291 | ): ReadonlySet<string> => { |
| 292 | const tags = new Set<string>(); |
| 293 | for (const member of members) { |
| 294 | const field = (member.fields as Record<string, unknown>)[tag] as |
| 295 | | { readonly literal?: unknown; readonly literals?: ReadonlyArray<unknown> } |
| 296 | | undefined; |
| 297 | for (const literal of field?.literals ?? [field?.literal]) { |
| 298 | if (typeof literal !== "string") { |
| 299 | throw new Error(`Every ForwardCompatibleUnion member needs a string literal ${tag}.`); |
| 300 | } |
| 301 | tags.add(literal); |
| 302 | } |
| 303 | } |
| 304 | return tags; |
| 305 | }; |
| 306 | |
| 307 | /** |
| 308 | * Construct a branded identifier. Enforces non-empty trimmed strings |
| 309 | */ |
| 310 | const makeEntityId = <Brand extends string>(brand: Parameters<typeof Schema.brand<Brand>>[0]) => { |
| 311 | return TrimmedNonEmptyString.pipe(Schema.brand<Brand>(brand)); |
| 312 | }; |
| 313 | |
| 314 | export const ThreadId = makeEntityId("ThreadId"); |
| 315 | export type ThreadId = typeof ThreadId.Type; |
| 316 | export const ProjectId = makeEntityId("ProjectId"); |
| 317 | export type ProjectId = typeof ProjectId.Type; |
| 318 | export const EnvironmentId = makeEntityId("EnvironmentId"); |
| 319 | export type EnvironmentId = typeof EnvironmentId.Type; |
| 320 | export const CommandId = makeEntityId("CommandId"); |
| 321 | export type CommandId = typeof CommandId.Type; |
| 322 | export const EventId = makeEntityId("EventId"); |
| 323 | export type EventId = typeof EventId.Type; |
| 324 | export const MessageId = makeEntityId("MessageId"); |
| 325 | export type MessageId = typeof MessageId.Type; |
| 326 | export const TurnId = makeEntityId("TurnId"); |
| 327 | export type TurnId = typeof TurnId.Type; |
| 328 | export const RunId = makeEntityId("RunId"); |
| 329 | export type RunId = typeof RunId.Type; |
| 330 | export const RunAttemptId = makeEntityId("RunAttemptId"); |
| 331 | export type RunAttemptId = typeof RunAttemptId.Type; |
| 332 | export const NodeId = makeEntityId("NodeId"); |
| 333 | export type NodeId = typeof NodeId.Type; |
| 334 | export const AuthSessionId = makeEntityId("AuthSessionId"); |
| 335 | export type AuthSessionId = typeof AuthSessionId.Type; |
| 336 | export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId")); |
| 337 | export type RpcClientId = typeof RpcClientId.Type; |
| 338 | |
| 339 | /** |
| 340 | * Which client surface a connection or command comes from. Unlike |
| 341 | * `AuthClientMetadataDeviceType` (a UA-style device class where web and |
| 342 | * desktop are both "desktop"), this names the actual product surface. |
| 343 | * Optional everywhere it appears: old clients never send it. |
| 344 | */ |
| 345 | export const ClientSurface = Schema.Literals(["web", "desktop", "mobile", "cli"]); |
| 346 | export type ClientSurface = typeof ClientSurface.Type; |
| 347 | |
| 348 | export const ClientOs = Schema.Literals([ |
| 349 | "macOS", |
| 350 | "Windows", |
| 351 | "Linux", |
| 352 | "iOS", |
| 353 | "Android", |
| 354 | "ChromeOS", |
| 355 | "other", |
| 356 | "unknown", |
| 357 | ]); |
| 358 | export type ClientOs = typeof ClientOs.Type; |
| 359 | |
| 360 | export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]); |
| 361 | export type ClientConnectionMethod = typeof ClientConnectionMethod.Type; |
| 362 | |
| 363 | export const ProviderItemId = makeEntityId("ProviderItemId"); |
| 364 | export type ProviderItemId = typeof ProviderItemId.Type; |
| 365 | export const ProviderSessionId = makeEntityId("ProviderSessionId"); |
| 366 | export type ProviderSessionId = typeof ProviderSessionId.Type; |
| 367 | export const ProviderThreadId = makeEntityId("ProviderThreadId"); |
| 368 | export type ProviderThreadId = typeof ProviderThreadId.Type; |
| 369 | export const ProviderTurnId = makeEntityId("ProviderTurnId"); |
| 370 | export type ProviderTurnId = typeof ProviderTurnId.Type; |
| 371 | export const RuntimeSessionId = makeEntityId("RuntimeSessionId"); |
| 372 | export type RuntimeSessionId = typeof RuntimeSessionId.Type; |
| 373 | export const RuntimeItemId = makeEntityId("RuntimeItemId"); |
| 374 | export type RuntimeItemId = typeof RuntimeItemId.Type; |
| 375 | export const TurnItemId = makeEntityId("TurnItemId"); |
| 376 | export type TurnItemId = typeof TurnItemId.Type; |
| 377 | export const RuntimeRequestId = makeEntityId("RuntimeRequestId"); |
| 378 | export type RuntimeRequestId = typeof RuntimeRequestId.Type; |
| 379 | export const RuntimeTaskId = makeEntityId("RuntimeTaskId"); |
| 380 | export type RuntimeTaskId = typeof RuntimeTaskId.Type; |
| 381 | export const ScheduledTaskId = makeEntityId("ScheduledTaskId"); |
| 382 | /** A one-use handle to a secret the user entered for an agent; the agent never sees the value. */ |
| 383 | export const SecretRef = makeEntityId("SecretRef"); |
| 384 | export type SecretRef = typeof SecretRef.Type; |
| 385 | export type ScheduledTaskId = typeof ScheduledTaskId.Type; |
| 386 | export const ApprovalRequestId = makeEntityId("ApprovalRequestId"); |
| 387 | export type ApprovalRequestId = typeof ApprovalRequestId.Type; |
| 388 | export const CheckpointRef = makeEntityId("CheckpointRef"); |
| 389 | export type CheckpointRef = typeof CheckpointRef.Type; |
| 390 | export const CheckpointId = makeEntityId("CheckpointId"); |
| 391 | export type CheckpointId = typeof CheckpointId.Type; |
| 392 | export const CheckpointScopeId = makeEntityId("CheckpointScopeId"); |
| 393 | export type CheckpointScopeId = typeof CheckpointScopeId.Type; |
| 394 | export const ContextHandoffId = makeEntityId("ContextHandoffId"); |
| 395 | export type ContextHandoffId = typeof ContextHandoffId.Type; |
| 396 | export const ContextTransferId = makeEntityId("ContextTransferId"); |
| 397 | export type ContextTransferId = typeof ContextTransferId.Type; |
| 398 | export const RawEventId = makeEntityId("RawEventId"); |
| 399 | export type RawEventId = typeof RawEventId.Type; |
| 400 | export const PlanId = makeEntityId("PlanId"); |
| 401 | export type PlanId = typeof PlanId.Type; |