Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/client-runtime/src/rpc/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,40 @@ describe("RpcSessionFactory", () => {
}),
);

it.effect("reaches ready when a newer server sends unknown config members", () =>
Effect.gen(function* () {
const { factory, sockets } = yield* makeFactory();
const session = yield* factory.connect(PREPARED);
const readyFiber = yield* Effect.forkChild(session.ready);
const socket = yield* awaitSocket(sockets);
socket.open();

const shortcut = {
key: "p",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: true,
};
yield* completeInitialConfig(socket, {
...ENCODED_SERVER_CONFIG,
keybindings: [
{ command: "someFuture.toggle", shortcut },
{ command: "terminal.toggle", shortcut },
],
issues: [{ kind: "keybindings.future-issue", message: "From a newer server" }],
availableEditors: ["some-future-editor", "zed"],
});
yield* Fiber.join(readyFiber);

const config = yield* session.initialConfig;
expect(config.keybindings).toEqual([{ command: "terminal.toggle", shortcut }]);
expect(config.issues).toEqual([]);
expect(config.availableEditors).toEqual(["zed"]);
}),
);

it.effect("uses the legacy config RPC for probes when the server lacks the capability", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
25 changes: 25 additions & 0 deletions packages/contracts/src/baseSchemas.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as SchemaTransformation from "effect/SchemaTransformation";

Expand All @@ -20,6 +21,30 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu
export const IsoDateTime = Schema.String;
export type IsoDateTime = typeof IsoDateTime.Type;

/**
* Wire codec for server→client arrays whose element unions grow over time
* (new literal members, new struct variants). Decoding drops elements the
* current build cannot decode instead of failing the whole payload — a client
* has to keep decoding configs sent by servers newer than itself, and
* rejecting the payload would take down the connection over data the client
* couldn't act on anyway. Encoding is the plain array encoding.
*/
export const ForwardCompatibleArray = <Element extends Schema.Top>(element: Element) => {
const decodeElement = Schema.decodeUnknownOption(element as never);
return Schema.Array(Schema.Unknown).pipe(
Schema.decodeTo(
Schema.Array(element),
SchemaTransformation.transform<ReadonlyArray<Element["Encoded"]>, ReadonlyArray<unknown>>({
decode: (values) =>
values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray<
Element["Encoded"]
>,
encode: (values) => values,
}),
),
);
};

/**
* Construct a branded identifier. Enforces non-empty trimmed strings
*/
Expand Down
65 changes: 65 additions & 0 deletions packages/contracts/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const decode = <S extends Schema.Top>(
>;

const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never);
const encodeResolvedKeybindings = Schema.encodeEffect(ResolvedKeybindingsConfig);

it.effect("parses keybinding rules", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -185,6 +186,70 @@ it.effect("parses resolved keybindings arrays", () =>
}),
);

const shortcut = {
key: "p",
metaKey: false,
ctrlKey: false,
shiftKey: false,
altKey: false,
modKey: true,
};

it.effect("drops resolved rules with commands this build does not know", () =>
Effect.gen(function* () {
const parsed = yield* decode(ResolvedKeybindingsConfig, [
{ command: "terminal.toggle", shortcut },
{ command: "someFuture.toggle", shortcut },
{ command: "filePicker.toggle", shortcut },
]);
assert.deepEqual(
parsed.map((rule) => rule.command),
["terminal.toggle", "filePicker.toggle"],
);
}),
);

it.effect("drops resolved rules with unknown when-node types", () =>
Effect.gen(function* () {
const parsed = yield* decode(ResolvedKeybindingsConfig, [
{
command: "terminal.toggle",
shortcut,
whenAst: { type: "xor", left: 1, right: 2 },
},
{ command: "terminal.split", shortcut },
]);
assert.deepEqual(
parsed.map((rule) => rule.command),
["terminal.split"],
);
}),
);

it.effect("drops malformed resolved rule entries", () =>
Effect.gen(function* () {
const parsed = yield* decode(ResolvedKeybindingsConfig, [
"garbage",
{ command: "terminal.toggle", shortcut },
null,
]);
assert.deepEqual(
parsed.map((rule) => rule.command),
["terminal.toggle"],
);
}),
);

it.effect("encodes resolved keybindings to the plain wire shape", () =>
Effect.gen(function* () {
const rules = [{ command: "terminal.toggle" as const, shortcut }];
const encoded = yield* encodeResolvedKeybindings(rules);
assert.deepEqual(encoded, rules);
const roundTripped = yield* decode(ResolvedKeybindingsConfig, encoded);
assert.deepEqual(roundTripped, rules);
}),
);

it.effect("drops unknown fields in resolved keybinding rules", () =>
decodeResolvedRule({
command: "terminal.toggle",
Expand Down
11 changes: 9 additions & 2 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Schema from "effect/Schema";
import { TrimmedString } from "./baseSchemas.ts";
import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts";

export const MAX_KEYBINDING_VALUE_LENGTH = 64;
export const MAX_KEYBINDING_WHEN_LENGTH = 256;
Expand Down Expand Up @@ -155,7 +155,14 @@ export const ResolvedKeybindingRule = Schema.Struct({
}).annotate({ parseOptions: { onExcessProperty: "ignore" } });
export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type;

export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check(
/**
* The command set grows over time, so a client may receive rules it cannot
* represent (a command or `when` node added after that client shipped).
* Decoding drops those rules instead of failing the whole payload —
* rejecting the config would take down the connection over a shortcut the
* client couldn't dispatch anyway.
*/
export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check(
Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
);
export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
Expand Down
26 changes: 25 additions & 1 deletion packages/contracts/src/server.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as Schema from "effect/Schema";
import { describe, expect, it } from "vite-plus/test";

import { ServerProvider } from "./server.ts";
import { ServerConfig, ServerProvider, ServerUpsertKeybindingResult } from "./server.ts";

const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider);
const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult);
const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors);

describe("ServerProvider", () => {
it("defaults capability arrays when decoding provider snapshots", () => {
Expand Down Expand Up @@ -72,3 +74,25 @@ describe("ServerProvider", () => {
expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex");
});
});

describe("server config forward compatibility", () => {
it("drops config issues with kinds this build does not know", () => {
const parsed = decodeUpsertKeybindingResult({
keybindings: [],
issues: [
{ kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 },
{ kind: "keybindings.future-issue", message: "From a newer server" },
],
});

expect(parsed.issues).toEqual([
{ kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 },
]);
});

it("drops editor ids this build does not know", () => {
const parsed = decodeAvailableEditors(["zed", "some-future-editor", "vscode"]);

expect(parsed).toEqual(["zed", "vscode"]);
});
});
9 changes: 7 additions & 2 deletions packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Schema from "effect/Schema";
import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts";
import { ServerAuthDescriptor } from "./auth.ts";
import {
ForwardCompatibleArray,
IsoDateTime,
NonNegativeInt,
PositiveInt,
Expand Down Expand Up @@ -38,7 +39,9 @@ export const ServerConfigIssue = Schema.Union([
]);
export type ServerConfigIssue = typeof ServerConfigIssue.Type;

const ServerConfigIssues = Schema.Array(ServerConfigIssue);
// Issue kinds grow over time; older clients must not fail the whole config
// decode over a kind they cannot render.
const ServerConfigIssues = ForwardCompatibleArray(ServerConfigIssue);

export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]);
export type ServerProviderState = typeof ServerProviderState.Type;
Expand Down Expand Up @@ -417,7 +420,9 @@ export const ServerConfig = Schema.Struct({
keybindings: ResolvedKeybindingsConfig,
issues: ServerConfigIssues,
providers: ServerProviders,
availableEditors: Schema.Array(EditorId),
// Editor ids grow over time; drop ones this build does not know rather than
// failing the whole config decode.
availableEditors: ForwardCompatibleArray(EditorId),
observability: ServerObservability,
settings: ServerSettings,
/** Whether shell subscriptions can emit an opt-in catch-up completion marker. */
Expand Down
Loading