1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import * as Schema from "effect/Schema";
4import * as SchemaGetter from "effect/SchemaGetter";
5import * as SchemaTransformation from "effect/SchemaTransformation";
6
7export 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 */
22const 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});
27export const TrimmedNonEmptyString = TrimmedString.check(isNonBlank);
28
29export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
30export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
31export 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 */
37export const DpopFailureReason = Schema.Literals([
38 "time_window",
39 "key_mismatch",
40 "request_mismatch",
41 "token_mismatch",
42 "replay",
43 "invalid_proof",
44]);
45export type DpopFailureReason = typeof DpopFailureReason.Type;
46
47export const IsoDateTime = Schema.String;
48export 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 */
55export 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 */
76export 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 */
95export 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 */
128export 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>;
171export 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 */
183export 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 */
201export 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};
228export 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 */
243export 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. */
256export 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 */
265export 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
288const 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 */
310const makeEntityId = <Brand extends string>(brand: Parameters<typeof Schema.brand<Brand>>[0]) => {
311 return TrimmedNonEmptyString.pipe(Schema.brand<Brand>(brand));
312};
313
314export const ThreadId = makeEntityId("ThreadId");
315export type ThreadId = typeof ThreadId.Type;
316export const ProjectId = makeEntityId("ProjectId");
317export type ProjectId = typeof ProjectId.Type;
318export const EnvironmentId = makeEntityId("EnvironmentId");
319export type EnvironmentId = typeof EnvironmentId.Type;
320export const CommandId = makeEntityId("CommandId");
321export type CommandId = typeof CommandId.Type;
322export const EventId = makeEntityId("EventId");
323export type EventId = typeof EventId.Type;
324export const MessageId = makeEntityId("MessageId");
325export type MessageId = typeof MessageId.Type;
326export const TurnId = makeEntityId("TurnId");
327export type TurnId = typeof TurnId.Type;
328export const RunId = makeEntityId("RunId");
329export type RunId = typeof RunId.Type;
330export const RunAttemptId = makeEntityId("RunAttemptId");
331export type RunAttemptId = typeof RunAttemptId.Type;
332export const NodeId = makeEntityId("NodeId");
333export type NodeId = typeof NodeId.Type;
334export const AuthSessionId = makeEntityId("AuthSessionId");
335export type AuthSessionId = typeof AuthSessionId.Type;
336export const RpcClientId = NonNegativeInt.pipe(Schema.brand("RpcClientId"));
337export 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 */
345export const ClientSurface = Schema.Literals(["web", "desktop", "mobile", "cli"]);
346export type ClientSurface = typeof ClientSurface.Type;
347
348export const ClientOs = Schema.Literals([
349 "macOS",
350 "Windows",
351 "Linux",
352 "iOS",
353 "Android",
354 "ChromeOS",
355 "other",
356 "unknown",
357]);
358export type ClientOs = typeof ClientOs.Type;
359
360export const ClientConnectionMethod = Schema.Literals(["direct", "ssh", "relay", "unknown"]);
361export type ClientConnectionMethod = typeof ClientConnectionMethod.Type;
362
363export const ProviderItemId = makeEntityId("ProviderItemId");
364export type ProviderItemId = typeof ProviderItemId.Type;
365export const ProviderSessionId = makeEntityId("ProviderSessionId");
366export type ProviderSessionId = typeof ProviderSessionId.Type;
367export const ProviderThreadId = makeEntityId("ProviderThreadId");
368export type ProviderThreadId = typeof ProviderThreadId.Type;
369export const ProviderTurnId = makeEntityId("ProviderTurnId");
370export type ProviderTurnId = typeof ProviderTurnId.Type;
371export const RuntimeSessionId = makeEntityId("RuntimeSessionId");
372export type RuntimeSessionId = typeof RuntimeSessionId.Type;
373export const RuntimeItemId = makeEntityId("RuntimeItemId");
374export type RuntimeItemId = typeof RuntimeItemId.Type;
375export const TurnItemId = makeEntityId("TurnItemId");
376export type TurnItemId = typeof TurnItemId.Type;
377export const RuntimeRequestId = makeEntityId("RuntimeRequestId");
378export type RuntimeRequestId = typeof RuntimeRequestId.Type;
379export const RuntimeTaskId = makeEntityId("RuntimeTaskId");
380export type RuntimeTaskId = typeof RuntimeTaskId.Type;
381export const ScheduledTaskId = makeEntityId("ScheduledTaskId");
382/** A one-use handle to a secret the user entered for an agent; the agent never sees the value. */
383export const SecretRef = makeEntityId("SecretRef");
384export type SecretRef = typeof SecretRef.Type;
385export type ScheduledTaskId = typeof ScheduledTaskId.Type;
386export const ApprovalRequestId = makeEntityId("ApprovalRequestId");
387export type ApprovalRequestId = typeof ApprovalRequestId.Type;
388export const CheckpointRef = makeEntityId("CheckpointRef");
389export type CheckpointRef = typeof CheckpointRef.Type;
390export const CheckpointId = makeEntityId("CheckpointId");
391export type CheckpointId = typeof CheckpointId.Type;
392export const CheckpointScopeId = makeEntityId("CheckpointScopeId");
393export type CheckpointScopeId = typeof CheckpointScopeId.Type;
394export const ContextHandoffId = makeEntityId("ContextHandoffId");
395export type ContextHandoffId = typeof ContextHandoffId.Type;
396export const ContextTransferId = makeEntityId("ContextTransferId");
397export type ContextTransferId = typeof ContextTransferId.Type;
398export const RawEventId = makeEntityId("RawEventId");
399export type RawEventId = typeof RawEventId.Type;
400export const PlanId = makeEntityId("PlanId");
401export type PlanId = typeof PlanId.Type;