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
3 changes: 2 additions & 1 deletion docs/adr/009-protocol-provider-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
- Status: accepted.
- Context: T4 pins one canonical `@oh-my-pi/app-wire` artifact through `@t4-code/protocol`, but the client previously constructed `omp-app/1` frames throughout its runtime. That made a future wire-version change touch transport, reconnect, pairing, terminal, and heartbeat code even when those behaviors had not changed.
- Decision: `OmpClient` consumes an `OmpProtocolProvider`. The provider contract receives T4 client messages and emits T4 server events, while each version-specific implementation owns wire frame construction, encoding, decoding, event normalization, command descriptions, and capability lookup. The current `omp-app/1` implementation lives separately from the provider contract. An immutable registry indexes providers by both implementation ID and declared protocol version, rejects duplicates, and supplies the pinned `omp-app/1` provider by default. A client may receive a provider directly or select one from a registry by ID, but it may not combine those two configuration paths. Application code may import the T4 protocol facade, but not the raw vendored app-wire package.
- Consequence: another wire implementation can be added beside `omp-app/1` and selected without duplicating transport or reconnect logic. Outbound wire shapes and version labels now have one owner. The client correlates requests, validates pairing, and tracks replay progress from normalized messages and payloads rather than version-specific frames. The live `ProjectionStore`, browser shell, desktop target manager, Electron IPC boundary, and renderer feature controllers consume the same version-free `{ kind, payload }` event union exported by `@t4-code/protocol`. Saved application state and renderer transport no longer require a wire-version field. Every concrete provider must run the shared conformance suite in addition to version-specific tests covering outbound wire shapes, inbound event normalization, injected-provider routing, and projection parity. The test-only `omp-app/2` provider uses a deliberately different envelope to prove that the shared client path does not depend on `omp-app/1` fields.
- Consequence: another wire implementation can be added beside `omp-app/1` and selected without duplicating transport or reconnect logic. Outbound wire shapes and version labels now have one owner. The client correlates requests, validates pairing, and tracks replay progress from normalized messages and payloads rather than version-specific frames. The live `ProjectionStore`, browser shell, desktop target manager, Electron IPC boundary, and renderer feature controllers consume the same version-free `{ kind, payload }` event union exported by `@t4-code/protocol`. Saved application state and renderer transport no longer require a wire-version field. Every concrete provider must run the shared conformance suite and a versioned golden corpus that checks exact outbound frames, exact normalized inbound events from both objects and JSON text, and fail-closed malformed inputs. Version-specific tests still cover injected-provider routing and projection parity. The test-only `omp-app/2` provider uses a deliberately different envelope and runs the same corpus contract to prove that the shared client path does not depend on `omp-app/1` fields.
- Runtime vocabulary: each provider declares an immutable list of the normalized server event kinds it supports. Direct injection and registry selection reject empty, mutable, duplicate, or unknown declarations before opening a transport, and the client fails closed if a provider later returns an undeclared event. T4's `OmpServerFrame` deliberately excludes the client-only `pair.start` variant that the upstream `ServerFrame` type includes but its server decoder cannot emit. The exhaustive event-kind table is checked by TypeScript against the remaining server-frame union, and the pinned provider decodes every canonical server fixture shipped by app-wire.
- Non-goals: this does not redefine OMP's canonical wire schema, add a production second protocol version, automatically guess or negotiate a wire format, or change the upstream OMP repository. Event payload fields mirror the current canonical contract until later decisions define narrower domain-specific payloads.
2 changes: 2 additions & 0 deletions packages/client/src/omp-app-v1-protocol-provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
COMMAND_DESCRIPTORS,
OMP_SERVER_EVENT_KINDS,
PROTOCOL_VERSION,
decodeClientFrame,
decodeServerFrame,
Expand Down Expand Up @@ -115,6 +116,7 @@ function encodeAppV1ClientMessage(message: OmpClientMessage): string {
export const ompAppV1ProtocolProvider: OmpProtocolProvider = Object.freeze({
id: "omp-app-v1",
protocolVersion: PROTOCOL_VERSION,
serverEventKinds: OMP_SERVER_EVENT_KINDS,
encodeClientMessage: encodeAppV1ClientMessage,
decodeServerEvent: (input: unknown) => ompServerEventFromFrame(decodeServerFrame(input)),
commandDescriptor: (command: string) => COMMAND_DESCRIPTORS[command],
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/omp-client-contracts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PairOkFrame, RequestId, Cursor, ServerFrame } from "@t4-code/protocol";
import type { PairOkFrame, RequestId, Cursor, OmpServerFrame } from "@t4-code/protocol";
import type { ProjectionStore } from "./projection.ts";
import type { OmpClientMessage, OmpPairOk, OmpProtocolProvider, OmpResponse } from "./omp-protocol-provider.ts";
import type { OmpProtocolProviderRegistry } from "./omp-protocol-provider-registry.ts";
Expand Down Expand Up @@ -95,7 +95,7 @@ export interface TerminalInputIntent { hostId: string; sessionId: string; termin
export interface TerminalResizeIntent { hostId: string; sessionId: string; terminalId: string; cols: number; rows: number; }
export interface TerminalCloseIntent { hostId: string; sessionId: string; terminalId: string; reason?: string; }
/** Pair credentials never cross this public subscription boundary. */
export type PublicServerFrame = Exclude<ServerFrame, PairOkFrame>;
export type PublicServerFrame = Exclude<OmpServerFrame, PairOkFrame>;

export const MAX_SAVED = 128;
export const MAX_PENDING = 256;
Expand Down
20 changes: 18 additions & 2 deletions packages/client/src/omp-client-frames.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
import { AppWireError } from "@t4-code/protocol";
import type { OmpServerEvent } from "./omp-protocol-provider.ts";
import type { OmpProtocolProvider, OmpServerEvent } from "./omp-protocol-provider.ts";

type ServerEvent<Kind extends OmpServerEvent["kind"]> = Extract<OmpServerEvent, { kind: Kind }>;
type DurableEvent = ServerEvent<"entry" | "event" | "session.delta">;
class ProtocolProviderContractError extends Error {
constructor() {
super("protocol provider returned an undeclared server event");
this.name = "ProtocolProviderContractError";
}
}
export function safeFrameDecodeFailure(error: unknown): string {
if (error instanceof ProtocolProviderContractError) return error.message;
if (!(error instanceof AppWireError)) return "invalid server frame";
const safePath = error.path !== undefined && /^[A-Za-z0-9.[\]_-]{1,128}$/u.test(error.path) ? ` at ${error.path}` : "";
return `invalid server frame (${error.code}${safePath})`;
}
export function decodeProviderServerEvent(
provider: OmpProtocolProvider,
input: unknown,
): OmpServerEvent {
const event = provider.decodeServerEvent(input);
if (!provider.serverEventKinds.includes(event.kind)) {
throw new ProtocolProviderContractError();
}
Comment thread
wolfiesch marked this conversation as resolved.
return event;
}
export interface FrameDispatchHandlers {
welcome(message: ServerEvent<"welcome">): void;
pong(nonce: string): void;
Expand Down Expand Up @@ -46,7 +63,6 @@ export class OmpClientEventDispatcher {
}
import type { CursorRecord, OmpClientOptions } from "./omp-client-contracts.ts";
import { encodeOutgoingMessage } from "./omp-client-outbound.ts";
import type { OmpProtocolProvider } from "./omp-protocol-provider.ts";

export function sendClientHello(
provider: OmpProtocolProvider,
Expand Down
7 changes: 5 additions & 2 deletions packages/client/src/omp-client-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { PendingRequests } from "./omp-client-pending.ts";
import { ClientTimerRegistry } from "./omp-client-timers.ts";
import { OmpClientEvents } from "./omp-client-events.ts";
import { OmpClientConnection } from "./omp-client-connection.ts";
import { OmpClientEventDispatcher, safeFrameDecodeFailure, sendClientHello } from "./omp-client-frames.ts";
import { decodeProviderServerEvent, OmpClientEventDispatcher, safeFrameDecodeFailure, sendClientHello } from "./omp-client-frames.ts";
import { OmpClientReconnectHealth } from "./omp-client-reconnect-health.ts";
import { encodeOutgoingMessage } from "./omp-client-outbound.ts";
import { resolveOmpProtocolProvider } from "./omp-protocol-provider-registry.ts";
Expand Down Expand Up @@ -440,7 +440,10 @@ export class OmpClient {
private handleRaw(raw: string | Uint8Array, generation: number): void | Promise<void> {
if (generation !== this.generation || this.closedByUser) return;
try {
return this.inboundDispatcher.dispatch(this.protocol.decodeServerEvent(raw), generation);
return this.inboundDispatcher.dispatch(
decodeProviderServerEvent(this.protocol, raw),
generation,
);
} catch (error) {
if (generation === this.generation) this.protocolFailure(safeFrameDecodeFailure(error));
}
Expand Down
31 changes: 29 additions & 2 deletions packages/client/src/omp-protocol-provider-registry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { OMP_SERVER_EVENT_KINDS } from "@t4-code/protocol";
import { ompAppV1ProtocolProvider } from "./omp-app-v1-protocol-provider.ts";
import type { OmpProtocolProvider } from "./omp-protocol-provider.ts";

const knownServerEventKinds: ReadonlySet<string> = new Set(OMP_SERVER_EVENT_KINDS);

function registryKey(value: string, label: string): string {
const hasControlCharacter = Array.from(value).some((character) => {
const codePoint = character.codePointAt(0);
Expand All @@ -12,6 +15,29 @@ function registryKey(value: string, label: string): string {
return value;
}

function validateProvider(provider: OmpProtocolProvider): OmpProtocolProvider {
registryKey(provider.id, "id");
registryKey(provider.protocolVersion, "version");
if (!Array.isArray(provider.serverEventKinds) || provider.serverEventKinds.length === 0) {
throw new Error(`protocol provider ${provider.id} must declare server event kinds`);
}
if (!Object.isFrozen(provider.serverEventKinds)) {
throw new Error(`protocol provider ${provider.id} server event kinds must be immutable`);
}
const eventKinds = new Set<string>();
for (const kind of provider.serverEventKinds) {
registryKey(kind, "server event kind");
if (!knownServerEventKinds.has(kind)) {
throw new Error(`unknown protocol provider server event kind: ${kind}`);
}
if (eventKinds.has(kind)) {
throw new Error(`duplicate protocol provider server event kind: ${kind}`);
}
eventKinds.add(kind);
}
return provider;
}

/** Immutable lookup table for concrete protocol adapters. */
export class OmpProtocolProviderRegistry {
readonly providers: readonly OmpProtocolProvider[];
Expand All @@ -25,7 +51,8 @@ export class OmpProtocolProviderRegistry {
}
const byId = new Map<string, OmpProtocolProvider>();
const byVersion = new Map<string, OmpProtocolProvider>();
for (const provider of providers) {
for (const candidate of providers) {
const provider = validateProvider(candidate);
const id = registryKey(provider.id, "id");
const version = registryKey(provider.protocolVersion, "version");
if (byId.has(id)) throw new Error(`duplicate protocol provider id: ${id}`);
Expand Down Expand Up @@ -70,7 +97,7 @@ export function resolveOmpProtocolProvider(options: {
if (options.protocolProviderId !== undefined || options.protocolProviderRegistry !== undefined) {
throw new Error("direct protocol provider cannot be combined with registry selection");
}
return options.protocolProvider;
return validateProvider(options.protocolProvider);
}
const registry = options.protocolProviderRegistry ?? defaultOmpProtocolProviderRegistry;
return registry.requireById(options.protocolProviderId);
Expand Down
2 changes: 2 additions & 0 deletions packages/client/src/omp-protocol-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ export type OmpClientMessage =
export interface OmpProtocolProvider {
readonly id: string;
readonly protocolVersion: string;
/** Normalized server events this adapter can validate and decode. */
readonly serverEventKinds: readonly OmpServerEvent["kind"][];
/** Validate and encode one logical T4 message using this provider's wire format. */
encodeClientMessage(message: OmpClientMessage): string;
decodeServerEvent(input: unknown): OmpServerEvent;
Expand Down
4 changes: 2 additions & 2 deletions packages/client/src/projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { isCursor } from "@t4-code/protocol";
import type {
Cursor,
DurableEntry,
OmpServerFrame,
SessionEvent,
SessionRef,
ServerFrame,
} from "@t4-code/protocol";
import { ImmutableSet } from "./immutable-set.ts";
import { ImmutableMap } from "./immutable-map.ts";
Expand Down Expand Up @@ -36,7 +36,7 @@ import {
} from "./transcript-retention.ts";
import type { PublicOmpServerEvent } from "./omp-protocol-provider.ts";

export type ProjectionFrame = Exclude<ServerFrame, Extract<ServerFrame, { type: "pair.ok" }>>;
export type ProjectionFrame = Exclude<OmpServerFrame, Extract<OmpServerFrame, { type: "pair.ok" }>>;
type ProjectionEventFrameFromEvent<Event extends PublicOmpServerEvent> = Event extends PublicOmpServerEvent
? Readonly<{ type: Event["kind"] } & Event["payload"]>
: never;
Expand Down
15 changes: 15 additions & 0 deletions packages/client/test/fixtures/protocol/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Protocol provider corpora

These files are synthetic contract examples. They do not contain captured user traffic, real
credentials, or private host data.

Each production `OmpProtocolProvider` should have a versioned corpus and run it through
`protocolProviderCorpus`. A corpus records:

- every logical client message kind and its exact encoded wire frame;
- representative inbound wire frames and their exact normalized T4 events; and
- malformed inputs that the provider must reject.

`schemaVersion` versions the corpus file shape, not the OMP wire protocol. Change a golden wire or
event only when the provider contract intentionally changes. Do not refresh expected values merely
to make a failing test pass; inspect the decoder or encoder change first.
Loading
Loading