| author | |
| committer | |
| log | df78cda8bf9c0971300e1bf35251774d9fbc833a |
| tree | 9d267cf4505f84e80406ee23d73dd89c1563af18 |
| parent | 605c2bb734bbebed0b19fc1ff6b514f41bd3659b |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>6 files changed, 165 insertions(+), 5 deletions(-)
packages/client-runtime/src/rpc/session.test.ts+34| ... | @@ -287,6 +287,40 @@ describe("RpcSessionFactory", () => { | ... | @@ -287,6 +287,40 @@ describe("RpcSessionFactory", () => { |
| 287 | }), | 287 | }), |
| 288 | ); | 288 | ); |
| 289 | 289 | ||
| 290 | it.effect("reaches ready when a newer server sends unknown config members", () => | ||
| 291 | Effect.gen(function* () { | ||
| 292 | const { factory, sockets } = yield* makeFactory(); | ||
| 293 | const session = yield* factory.connect(PREPARED); | ||
| 294 | const readyFiber = yield* Effect.forkChild(session.ready); | ||
| 295 | const socket = yield* awaitSocket(sockets); | ||
| 296 | socket.open(); | ||
| 297 | |||
| 298 | const shortcut = { | ||
| 299 | key: "p", | ||
| 300 | metaKey: false, | ||
| 301 | ctrlKey: false, | ||
| 302 | shiftKey: false, | ||
| 303 | altKey: false, | ||
| 304 | modKey: true, | ||
| 305 | }; | ||
| 306 | yield* completeInitialConfig(socket, { | ||
| 307 | ...ENCODED_SERVER_CONFIG, | ||
| 308 | keybindings: [ | ||
| 309 | { command: "someFuture.toggle", shortcut }, | ||
| 310 | { command: "terminal.toggle", shortcut }, | ||
| 311 | ], | ||
| 312 | issues: [{ kind: "keybindings.future-issue", message: "From a newer server" }], | ||
| 313 | availableEditors: ["some-future-editor", "zed"], | ||
| 314 | }); | ||
| 315 | yield* Fiber.join(readyFiber); | ||
| 316 | |||
| 317 | const config = yield* session.initialConfig; | ||
| 318 | expect(config.keybindings).toEqual([{ command: "terminal.toggle", shortcut }]); | ||
| 319 | expect(config.issues).toEqual([]); | ||
| 320 | expect(config.availableEditors).toEqual(["zed"]); | ||
| 321 | }), | ||
| 322 | ); | ||
| 323 | |||
| 290 | it.effect("uses the legacy config RPC for probes when the server lacks the capability", () => | 324 | it.effect("uses the legacy config RPC for probes when the server lacks the capability", () => |
| 291 | Effect.scoped( | 325 | Effect.scoped( |
| 292 | Effect.gen(function* () { | 326 | Effect.gen(function* () { |
packages/contracts/src/baseSchemas.ts+25| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Option from "effect/Option"; | ||
| 2 | import * as Schema from "effect/Schema"; | 3 | import * as Schema from "effect/Schema"; |
| 3 | import * as SchemaTransformation from "effect/SchemaTransformation"; | 4 | import * as SchemaTransformation from "effect/SchemaTransformation"; |
| 4 | 5 | ||
| ... | @@ -20,6 +21,30 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu | ... | @@ -20,6 +21,30 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu |
| 20 | export const IsoDateTime = Schema.String; | 21 | export const IsoDateTime = Schema.String; |
| 21 | export type IsoDateTime = typeof IsoDateTime.Type; | 22 | export type IsoDateTime = typeof IsoDateTime.Type; |
| 22 | 23 | ||
| 24 | /** | ||
| 25 | * Wire codec for server→client arrays whose element unions grow over time | ||
| 26 | * (new literal members, new struct variants). Decoding drops elements the | ||
| 27 | * current build cannot decode instead of failing the whole payload — a client | ||
| 28 | * has to keep decoding configs sent by servers newer than itself, and | ||
| 29 | * rejecting the payload would take down the connection over data the client | ||
| 30 | * couldn't act on anyway. Encoding is the plain array encoding. | ||
| 31 | */ | ||
| 32 | export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => { | ||
| 33 | const decodeElement = Schema.decodeUnknownOption(element as never); | ||
| 34 | return Schema.Array(Schema.Unknown).pipe( | ||
| 35 | Schema.decodeTo( | ||
| 36 | Schema.Array(element), | ||
| 37 | SchemaTransformation.transform<ReadonlyArray<Element["Encoded"]>, ReadonlyArray<unknown>>({ | ||
| 38 | decode: (values) => | ||
| 39 | values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< | ||
| 40 | Element["Encoded"] | ||
| 41 | >, | ||
| 42 | encode: (values) => values, | ||
| 43 | }), | ||
| 44 | ), | ||
| 45 | ); | ||
| 46 | }; | ||
| 47 | |||
| 23 | /** | 48 | /** |
| 24 | * Construct a branded identifier. Enforces non-empty trimmed strings | 49 | * Construct a branded identifier. Enforces non-empty trimmed strings |
| 25 | */ | 50 | */ |
packages/contracts/src/keybindings.test.ts+65| ... | @@ -20,6 +20,7 @@ const decode = <S extends Schema.Top>( | ... | @@ -20,6 +20,7 @@ const decode = <S extends Schema.Top>( |
| 20 | >; | 20 | >; |
| 21 | 21 | ||
| 22 | const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); | 22 | const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); |
| 23 | const encodeResolvedKeybindings = Schema.encodeEffect(ResolvedKeybindingsConfig); | ||
| 23 | 24 | ||
| 24 | it.effect("parses keybinding rules", () => | 25 | it.effect("parses keybinding rules", () => |
| 25 | Effect.gen(function* () { | 26 | Effect.gen(function* () { |
| ... | @@ -185,6 +186,70 @@ it.effect("parses resolved keybindings arrays", () => | ... | @@ -185,6 +186,70 @@ it.effect("parses resolved keybindings arrays", () => |
| 185 | }), | 186 | }), |
| 186 | ); | 187 | ); |
| 187 | 188 | ||
| 189 | const shortcut = { | ||
| 190 | key: "p", | ||
| 191 | metaKey: false, | ||
| 192 | ctrlKey: false, | ||
| 193 | shiftKey: false, | ||
| 194 | altKey: false, | ||
| 195 | modKey: true, | ||
| 196 | }; | ||
| 197 | |||
| 198 | it.effect("drops resolved rules with commands this build does not know", () => | ||
| 199 | Effect.gen(function* () { | ||
| 200 | const parsed = yield* decode(ResolvedKeybindingsConfig, [ | ||
| 201 | { command: "terminal.toggle", shortcut }, | ||
| 202 | { command: "someFuture.toggle", shortcut }, | ||
| 203 | { command: "filePicker.toggle", shortcut }, | ||
| 204 | ]); | ||
| 205 | assert.deepEqual( | ||
| 206 | parsed.map((rule) => rule.command), | ||
| 207 | ["terminal.toggle", "filePicker.toggle"], | ||
| 208 | ); | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | |||
| 212 | it.effect("drops resolved rules with unknown when-node types", () => | ||
| 213 | Effect.gen(function* () { | ||
| 214 | const parsed = yield* decode(ResolvedKeybindingsConfig, [ | ||
| 215 | { | ||
| 216 | command: "terminal.toggle", | ||
| 217 | shortcut, | ||
| 218 | whenAst: { type: "xor", left: 1, right: 2 }, | ||
| 219 | }, | ||
| 220 | { command: "terminal.split", shortcut }, | ||
| 221 | ]); | ||
| 222 | assert.deepEqual( | ||
| 223 | parsed.map((rule) => rule.command), | ||
| 224 | ["terminal.split"], | ||
| 225 | ); | ||
| 226 | }), | ||
| 227 | ); | ||
| 228 | |||
| 229 | it.effect("drops malformed resolved rule entries", () => | ||
| 230 | Effect.gen(function* () { | ||
| 231 | const parsed = yield* decode(ResolvedKeybindingsConfig, [ | ||
| 232 | "garbage", | ||
| 233 | { command: "terminal.toggle", shortcut }, | ||
| 234 | null, | ||
| 235 | ]); | ||
| 236 | assert.deepEqual( | ||
| 237 | parsed.map((rule) => rule.command), | ||
| 238 | ["terminal.toggle"], | ||
| 239 | ); | ||
| 240 | }), | ||
| 241 | ); | ||
| 242 | |||
| 243 | it.effect("encodes resolved keybindings to the plain wire shape", () => | ||
| 244 | Effect.gen(function* () { | ||
| 245 | const rules = [{ command: "terminal.toggle" as const, shortcut }]; | ||
| 246 | const encoded = yield* encodeResolvedKeybindings(rules); | ||
| 247 | assert.deepEqual(encoded, rules); | ||
| 248 | const roundTripped = yield* decode(ResolvedKeybindingsConfig, encoded); | ||
| 249 | assert.deepEqual(roundTripped, rules); | ||
| 250 | }), | ||
| 251 | ); | ||
| 252 | |||
| 188 | it.effect("drops unknown fields in resolved keybinding rules", () => | 253 | it.effect("drops unknown fields in resolved keybinding rules", () => |
| 189 | decodeResolvedRule({ | 254 | decodeResolvedRule({ |
| 190 | command: "terminal.toggle", | 255 | command: "terminal.toggle", |
packages/contracts/src/keybindings.ts+9-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { TrimmedString } from "./baseSchemas.ts"; | 2 | import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; |
| 3 | 3 | ||
| 4 | export const MAX_KEYBINDING_VALUE_LENGTH = 64; | 4 | export const MAX_KEYBINDING_VALUE_LENGTH = 64; |
| 5 | export const MAX_KEYBINDING_WHEN_LENGTH = 256; | 5 | export const MAX_KEYBINDING_WHEN_LENGTH = 256; |
| ... | @@ -155,7 +155,14 @@ export const ResolvedKeybindingRule = Schema.Struct({ | ... | @@ -155,7 +155,14 @@ export const ResolvedKeybindingRule = Schema.Struct({ |
| 155 | }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); | 155 | }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); |
| 156 | export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; | 156 | export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; |
| 157 | 157 | ||
| 158 | export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( | 158 | /** |
| 159 | * The command set grows over time, so a client may receive rules it cannot | ||
| 160 | * represent (a command or `when` node added after that client shipped). | ||
| 161 | * Decoding drops those rules instead of failing the whole payload — | ||
| 162 | * rejecting the config would take down the connection over a shortcut the | ||
| 163 | * client couldn't dispatch anyway. | ||
| 164 | */ | ||
| 165 | export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check( | ||
| 159 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), | 166 | Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), |
| 160 | ); | 167 | ); |
| 161 | export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; | 168 | export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; |
packages/contracts/src/server.test.ts+25-1| ... | @@ -1,9 +1,11 @@ | ... | @@ -1,9 +1,11 @@ |
| 1 | import * as Schema from "effect/Schema"; | 1 | import * as Schema from "effect/Schema"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { ServerProvider } from "./server.ts"; | 4 | import { ServerConfig, ServerProvider, ServerUpsertKeybindingResult } from "./server.ts"; |
| 5 | 5 | ||
| 6 | const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); | 6 | const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); |
| 7 | const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); | ||
| 8 | const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); | ||
| 7 | 9 | ||
| 8 | describe("ServerProvider", () => { | 10 | describe("ServerProvider", () => { |
| 9 | it("defaults capability arrays when decoding provider snapshots", () => { | 11 | it("defaults capability arrays when decoding provider snapshots", () => { |
| ... | @@ -72,3 +74,25 @@ describe("ServerProvider", () => { | ... | @@ -72,3 +74,25 @@ describe("ServerProvider", () => { |
| 72 | expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); | 74 | expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); |
| 73 | }); | 75 | }); |
| 74 | }); | 76 | }); |
| 77 | |||
| 78 | describe("server config forward compatibility", () => { | ||
| 79 | it("drops config issues with kinds this build does not know", () => { | ||
| 80 | const parsed = decodeUpsertKeybindingResult({ | ||
| 81 | keybindings: [], | ||
| 82 | issues: [ | ||
| 83 | { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, | ||
| 84 | { kind: "keybindings.future-issue", message: "From a newer server" }, | ||
| 85 | ], | ||
| 86 | }); | ||
| 87 | |||
| 88 | expect(parsed.issues).toEqual([ | ||
| 89 | { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, | ||
| 90 | ]); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("drops editor ids this build does not know", () => { | ||
| 94 | const parsed = decodeAvailableEditors(["zed", "some-future-editor", "vscode"]); | ||
| 95 | |||
| 96 | expect(parsed).toEqual(["zed", "vscode"]); | ||
| 97 | }); | ||
| 98 | }); |
packages/contracts/src/server.ts+7-2| ... | @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema"; |
| 3 | import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; | 3 | import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; |
| 4 | import { ServerAuthDescriptor } from "./auth.ts"; | 4 | import { ServerAuthDescriptor } from "./auth.ts"; |
| 5 | import { | 5 | import { |
| 6 | ForwardCompatibleArray, | ||
| 6 | IsoDateTime, | 7 | IsoDateTime, |
| 7 | NonNegativeInt, | 8 | NonNegativeInt, |
| 8 | PositiveInt, | 9 | PositiveInt, |
| ... | @@ -38,7 +39,9 @@ export const ServerConfigIssue = Schema.Union([ | ... | @@ -38,7 +39,9 @@ export const ServerConfigIssue = Schema.Union([ |
| 38 | ]); | 39 | ]); |
| 39 | export type ServerConfigIssue = typeof ServerConfigIssue.Type; | 40 | export type ServerConfigIssue = typeof ServerConfigIssue.Type; |
| 40 | 41 | ||
| 41 | const ServerConfigIssues = Schema.Array(ServerConfigIssue); | 42 | // Issue kinds grow over time; older clients must not fail the whole config |
| 43 | // decode over a kind they cannot render. | ||
| 44 | const ServerConfigIssues = ForwardCompatibleArray(ServerConfigIssue); | ||
| 42 | 45 | ||
| 43 | export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); | 46 | export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); |
| 44 | export type ServerProviderState = typeof ServerProviderState.Type; | 47 | export type ServerProviderState = typeof ServerProviderState.Type; |
| ... | @@ -417,7 +420,9 @@ export const ServerConfig = Schema.Struct({ | ... | @@ -417,7 +420,9 @@ export const ServerConfig = Schema.Struct({ |
| 417 | keybindings: ResolvedKeybindingsConfig, | 420 | keybindings: ResolvedKeybindingsConfig, |
| 418 | issues: ServerConfigIssues, | 421 | issues: ServerConfigIssues, |
| 419 | providers: ServerProviders, | 422 | providers: ServerProviders, |
| 420 | availableEditors: Schema.Array(EditorId), | 423 | // Editor ids grow over time; drop ones this build does not know rather than |
| 424 | // failing the whole config decode. | ||
| 425 | availableEditors: ForwardCompatibleArray(EditorId), | ||
| 421 | observability: ServerObservability, | 426 | observability: ServerObservability, |
| 422 | settings: ServerSettings, | 427 | settings: ServerSettings, |
| 423 | /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ | 428 | /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ |