blob: b1f16e7e5baf8cb667289939233a765fca381ad5 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { requireCount, requireId, requireRecord, requireString } from './codec.js';
import { invalidProtocolFrame, RuntimeHostProtocolError } from './errors.js';
import {
decodeHostActivitySnapshot,
requireHostLifecycleState,
type HostActivitySnapshot,
} from './host-status.js';
import {
decodeSubscriptionFrame,
isSubscriptionFrameKind,
type SubscriptionFrame,
} from './session-continuity.js';
import {
decodeClientCapabilityClientFrame,
decodeClientCapabilityHostFrame,
isClientCapabilityClientFrameKind,
isClientCapabilityHostFrameKind,
type ClientCapabilityClientFrame,
type ClientCapabilityHostFrame,
} from './client-capability.js';
import {
decodeConfigurationChangedFrame,
type ConfigurationChangedFrame,
} from './configuration-change.js';
import {
decodeSessionCatalogChangedFrame,
type SessionCatalogChangedFrame,
} from './session-catalog-change.js';
import {
decodeScheduledTaskChangedFrame,
type ScheduledTaskChangedFrame,
} from './scheduled-task-change.js';
import {
decodeProjectCatalogChangedFrame,
type ProjectCatalogChangedFrame,
} from './project-catalog-change.js';
import {
decodeConnectionCatalogChangedFrame,
type ConnectionCatalogChangedFrame,
} from './connection-catalog-change.js';
import {
decodeRequestFrame,
decodeResponseFrame,
type HostLifecycleState,
type RequestFrame,
type ResponseFrame,
} from './operations.js';
import { isCanonicalRuntimeHostWebSocketPath } from './websocket-path.js';
export * from './access-authority.js';
export * from './agent-graph.js';
export * from './interaction.js';
export * from './daily-review.js';
export * from './client-capability.js';
export * from './configuration-change.js';
export * from './connection-catalog-change.js';
export * from './goal.js';
export * from './hosted-execution.js';
export * from './host-resources.js';
export * from './plan.js';
export * from './peer-mesh.js';
export * from './project-catalog.js';
export * from './project-catalog-change.js';
export * from './execution-inspect.js';
export * from './external-session.js';
export * from './message.js';
export * from './operations.js';
export * from './runtime-resource.js';
export * from './session-continuity.js';
export * from './session-catalog-change.js';
export * from './session-collaboration.js';
export * from './scheduled-task-change.js';
export * from './session-retirement.js';
export * from './session-transcript.js';
export * from './session-turns.js';
export * from './session-todo.js';
export * from './workspace.js';
export * from './workhub-coordination.js';
export * from './websocket-path.js';
export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 154 as const;
// 154: External Session import results distinguish committed Sessions from typed source limits.
// 153: Sessions may select plugin executors and Plugin Platform queries expose them.
// 152: Assistant completions and transcript rows preserve interrupted responses.
// 151: WorkHub selects and delegates through a durable Host Form interaction.
// 150: Message admission accepts an empty-text Message that carries a quote or
// an attachment (#4804). Peers older than this epoch reject that frame at
// admission, so the pair must refuse each other at the handshake.
// 149: Connection model overrides retain disabled identities and separate capacity
// from compaction. Catalog entries carry overrides; clients do not rebuild them.
// 148: Model catalog entries include image support before a user override.
// 147: OAuth create targets may carry a caller-selected Connection name and
// slug, and slug collisions remain a closed typed error before or after
// authorization. Older peers reject those strict input and output shapes.
// 146: Code Mode settings and Session tool mode join the epoch-145 Host contract.
// 145: Combine Antigravity setup with Session bundle Host operations and explicit
// missing/archived Skill query refusals.
// 144: Antigravity setup combined with explicit missing/archived Skill query refusals.
// 143: Session bundle export and import are Host operations. Pre-merge Antigravity
// builds also advertised 143 without this contract and remain incompatible.
// 142: Invocable Skill queries expose missing and archived Session refusals explicitly.
// 141: WorkHub root admissions bind model Intent/Recall decisions before actions.
// 140: Plugin Platform queries expose scoped Command contribution projections.
// Epoch-139 peers reject the added query view and result shape.
// 139: WorkHub recovery preserves the Host-authenticated Desktop capability binding.
// 138: Removes the unused steering display anchor from canonical MessageContent.
// 137: Reserved by the former display anchor contract.
// 136: WorkHub transient proposals distinguish routing dispositions from linked
// operations. Older peers expect replace/stop_work/resume_work dispositions.
// 135: WorkHub model Turns replace direct action proposals with active-Turn task tools.
// 134: Coordination actions own real Runtime Turns. Removes the synthetic record
// operation, projects typed action receipts and admitted action identities, and
// distinguishes stale candidate refusals and resumable transcript preparation.
// 133: WorkHub actions carry attachments and new-Work model/permission defaults.
// Epoch-132 peers reject these additional fields on strict action shapes.
// 132: new Tool Result archives use versioned ledger references, not Artifact payloads.
// 131: Logical model steps bind durable Request Composition identities.
// 130: Turn contributions carry the optional bounded `failureMessage` diagnostic.
// Epoch-129 peers reject this added field on the strict contribution shape.
// 129: Turn states and Turn records drop `partialOutputRetained`. The fact was
// derived twice — once from the Turn's output rows, once off the state message
// — and read by nothing; older peers require the field on both.
// 128: Session transcript bootstraps drop `durableCoverage`. A durable sequence
// is an event ordinal times its stride, so no projection has contiguous
// sequences any more and the claim the field made is unavailable to make.
// 127: Session Turn contributions carry only the Turn's recorded state. Older
// peers require the derived shape booleans this projection no longer sends.
// 126: Durable transcript cursors seek Session event ordinals instead of run indexes.
// 125: Live Turn snapshots carry an optional `rootExecutionKind:'context_compact'`
// so a running context-compaction Turn can render a transcript row. Epoch-124
// peers reject the added optional field on the strict live snapshot shape.
// 124: PTY delivery is independent of the ordered Session state stream. A
// bounded PTY overflow requests terminal-only snapshot recovery.
// 123: Failed turns carry canonical retry decisions through bounded projections.
// 122: Authenticated physical handoff continuations retain logical Turn identity.
// Older peers cannot decode the handoff source and sealed invocation facts.
// 121: Host diagnostics report `upgradeBlockingActivity`, the Host's
// authoritative activity answer for maintenance probes, computed by the same
// authority that gates `host.upgrade.prepare`. Older Clients reject the
// unknown key when decoding diagnostics, so the pair must refuse each other
// at the handshake.
// 120: WorkHub admits named resume proposals with an explicit resumesActionId
// and returns a transient resume outcome. Older peers cannot decode this action.
// 119: Session Guest principals expose optional display names and an owner-only
// rename command. Older peers reject named principal projections.
// 118: External-session import publishes distinct `model_unavailable` and
// `source_unreadable` error codes so the shell classifies failures by code
// instead of the redacted message. Older peers cannot decode the new codes.
// 117: WorkHub exposes only one correction linkage per bounded candidate and
// no longer returns the Host's complete active-link set.
// 116: User deletion rejects workflow-owned Artifacts with operation_conflict.
// 115: Artifact creation requires explicit source ownership.
// 114: Artifacts are physically deleted and no longer expose tombstone status.
// 113: Client Capability tool schemas add `patternProperties` and draft-07 tuple
// `additionalItems`; validation and projection share one per-keyword shape table.
// Older peers reject these keywords and fail the handshake.
// 112: Owners can query the Host execution environment through an extensible,
// bounded resource-envelope contract. Older Hosts do not implement the query.
// 111: Client Capability tool schemas may use draft-07 tuple additionalItems.
// Older Hosts reject the keyword, so peers must agree before capabilities are admitted.
// 110: Runtime Host is the sole schema-migration authority for its State Root.
// Epoch 109 Desktop builds could migrate the event-only AgentRun schema while
// an older service Host still held the root, leaving that Host querying a
// removed column. Reject the affected mixed generation before either process
// admits domain work; the installation owner can then replace the Host.
// 109: accepted Client Capability invocations may carry one bounded nested form
// Interaction request/result round trip.
// 108: Session Interaction snapshots, forwarded Runtime events, and Agent Graph
// activity may carry the provider-neutral `form` request/answer contract.
// 107: `token_usage` anchors record the model and connection that produced
// them. The record decodes against a closed allowlist, so an older client
// rejects the two new keys and, with them, the Session that carries them.
// 106: Session transcripts gain five `system_note` kinds
// (`context_provider_dropping`, `context_window_suggestion`,
// `context_window_overrun`, `context_reported_window_exceeded`,
// `context_overflow_after_compaction`) and
// `token_usage` records reshape `lastRequestAnchor` to
// `{ inputTokens, outputTokens }`, all behind closed allowlists in
// @maka/core. An older client that handshakes would fail
// `decodeStoredMessage` on the first transcript carrying them, so the pair
// must refuse each other at the handshake instead (#4559).
// 105: Usage summaries may carry the recorded call-time total and per-Session
// tool-invocation totals. Older Clients reject the unknown fields, so a newer
// Host's usage summary is unreadable to them.
// 104: WorkHub Coordination actions add closed direct-stop proposals,
// confirmations, expected-state preconditions, and outcomes. Older peers
// reject these strict shapes.
// 103: `github-copilot` joins `OAUTH_LOGIN_PROVIDERS`, the Host answers the
// closed `oauth.enrollment.query`, and `connection.onboarding.save` admits
// canonical OAuth material with an empty enable-all-discovered selection.
// Older peers reject these wire values, so incompatible pairs must fail the
// handshake. Re-derived from current `main`; epoch 102 is claimed by open PRs.
// 101: Session Turn requests can carry regeneration intents and Guests can
// atomically withdraw pending requests. Older peers do not share this command
// vocabulary or the expanded Guest operation grant.
// 100: `session.branch.create` makes `sourceTurnId` optional, so a side
// conversation can fork with an empty context (no copied messages, no
// fabricated `branchOfTurnId`) instead of requiring a settled turn. An older
// Host's required-field check rejects the request that omits `sourceTurnId`;
// the handshake keeps mixed-version peers apart. `session.revision.create`
// still requires `sourceTurnId`, and its wire shape and fingerprint are
// unchanged.
// 99: ScheduledTask Agent execution templates carry immutable Connection
// identity. Older peers cannot preserve the ID/slug/model binding and could
// silently route a deleted Connection to a same-slug replacement.
// 98: Peer Mesh invitations carry signed reachability leases and member route
// projections use the convergent recovery state machine. Older peers decode a
// different strict wire shape.
// 97: Host status replaces unsigned route arrays with a self-signed, bounded
// reachability lease. Older peers cannot validate the locator revision or its
// target identity before retaining it for reconnect.
// 96: Read image tool results may carry durable `session_context` refs.
// 95: Catalog entries carry `describedByMetadata`, so a client asks the
// Host-resolved entry — not its own bundled table — whether a model needs a
// hand-written capability declaration. The field is required, so a newer Host's
// entry fails an older client's strict decoder, and an older Host's entry
// (lacking it) fails a newer client's.
// 94: A failed Turn snapshot no longer carries contextBudgetExhaustedDetail; the
// retired outcome reads as context_overflow at the ledger boundary, and an older
// Host still sending the field fails a newer client's closed snapshot decode.
// 93: Configuration credential transfer binds proxy destinations and
// Connection credentials to exact Host-owned targets before secret access.
// Proxy policy and credentials commit through one recoverable Host command;
// older peers can split the writes and violate the shared credential basis.
// 92: Owners can query their complete pending Session Turn-request inbox.
// 91: Host status publishes the live Direct peer endpoint so newly issued
// connection invitations do not preserve stale startup routes.
// 90: `session.create.mode` accepts the Bot session mode. A Host that predates
// it rejects the value as an invalid Session start mode.
// 89: The Host refreshes its models.dev catalog at startup and announces the
// swap with a `connection.catalog.changed` frame, which an older client's
// strict frame decoder rejects as an unknown kind.
// 88: Catalog model modalities admit video on either side and pdf as output.
// models.dev declares both, and the modality decoder rejects any value it does
// not name, so a newer Host describing such a model fails an older client's
// catalog decode outright rather than losing one field. The handshake keeps
// that pairing from forming; a newer client simply never sees the new values
// from an older Host.
// 87: The connection catalog projects each model as the Host resolved it —
// a `catalog_entry` item per model, counted by the connection header. Clients
// render those entries instead of merging the stored row against their own
// bundled model metadata, so a Desktop and a TUI attached to one Host cannot
// describe the same model differently. An older client ignores the new items
// but would still resolve locally; an older Host sends none, leaving a newer
// client with an empty catalog. Both are rejected at the handshake.
// 86: Client Capability accepted frames carry typed admission evidence used to
// enforce Session Grant scopes. Older peers cannot preserve that boundary.
// 85: Plugin package and Entry composition operations become Host-owned protocol
// surfaces. Older peers cannot safely exchange these strict operation shapes.
// 84: Message content carries Host-bound directory references. Older peers
// reject this field and cannot preserve its identity through admission/replay.
// 83: WorkHub Coordination actions add linked replacement proposals,
// destructive user confirmation, and replacement results. Older peers reject
// these closed action and result shapes.
// 82: Session removal reports how many linked subtasks it archived, and adds a
// `session.remove.preview` query for that count before the delete. Older peers
// reject the extra removed-result field and the unknown operation.
// 81: SessionTodo replaces the Task Ledger protocol and continuity domain with
// one bounded current-state snapshot. Older peers cannot decode the operation
// or preserve the new invalidation vocabulary.
// 80: Runtime Policy catalog models gained validated user-overridden fact
// provenance. Older peers reject this projected model shape, so they must be
// refused during the handshake before catalog admission.
// 79: Every `turn.message.submit` disposition carries the exact Skill
// invocation outcome. Durable queued replays may omit the previous Host
// Epoch's transient queue revision; older strict peers reject either shape.
// 78: OAuth login targets explicit create/existing Connection entities and
// returns their canonical identity. Older peers reject both closed wire shapes.
// 77: LLM and tool usage-log projections carry an optional `sessionTitle` (the
// Host-resolved session name for the usage Task column). Older Clients reject
// the unknown field, so a newer Host's usage logs are unreadable to them.
// 76: Peer Mesh endpoint and Mesh display names are signed, persisted facts
// managed through Host operations rather than local-only Client labels.
// 75: Peer Mesh routes identify whether a peer is a Client or Runtime Host so
// management surfaces can present the endpoint authority boundary accurately.
// 74: Capability-provider credentials may carry one Host-authenticated owner
// identity. Older peers cannot preserve the association and could select an
// unrelated provider for an interactive Session.
// 73: Transcript pages carry a Host-owned Turn range boundary. Older peers
// cannot preserve both the complete edge Turn and the bounded projection.
// 72: Collaboration Turn request query results require `canRequestTurns`.
// Older peers reject the new closed result shape.
// 71: Session Guests can submit durable exact Turn access requests and Owners
// can decide them. Older peers do not understand this execution-authority flow.
// 70: Session Guest connections receive resource-scoped shared catalog and
// continuity projections. Older peers cannot enforce the Session grant fence.
// 69: Runtime Host access authority recognizes restricted Session Guest
// principals and typed Session collaboration grants. Older Hosts would either
// reject the new operations or misclassify the authenticated principal.
// 68: Connection onboarding replaces nullable canonical-slug targeting with
// explicit create/existing identity and returns the committed Connection.
// Older peers reject the closed target and saved-result shapes.
// 67: Message lifecycle queries expose durable execution ownership and
// cancellation. Older peers cannot decode or provide the closed proof list.
// 66: Peer Mesh queries expose one canonical transit selection and runtime metrics.
// 65: live `tool_start` frames may carry optional `intent` / `argsPreview`
// keys. Older Clients decode the event with a strict allowed-key list and tear
// the connection down on unknown keys, so the pair must be refused up front.
// The strict decoder's allowed-key union also retains `shellRunRef`.
// 64: execution.inspect drops the retired resolve operation. Older peers still
// know execution.inspect.resolve and would send it only to fail mid-connection,
// so removing it needs its own handshake boundary.
// 63: Connection updates accept the full canonical enabled-model limit.
// Older peers reject valid catalogs containing more than 64 enabled models.
// 62: A Direct peer listener can expose owner-only Peer Mesh management
// operations. Older peers do not have this closed operation vocabulary.
// 61: Session explicit model targets carry immutable Connection identity,
// configuration updates are Host-merged patches, and projections expose the
// required nullable binding ID. Older peers cannot preserve these invariants.
// 60: WorkHub stores a canonical delegation assignment record. Older peers
// cannot decode this message during transcript recovery.
// 59: Scheduled Turn provider-retry frames may carry an optional host-clock
// `ts`, letting a mid-wait re-projection recompute the authoritative
// remaining duration. Older peers decode the frame with an exact key list
// and reject the added field, so mixed peers must fail the handshake.
// 58: `runtime.resource.start` accepts an optional one-shot `command`, and the
// durable Shell Run record carries a `visibility` field. An epoch-57 Host
// rejects the widened closed input, while an epoch-57 binary cannot safely
// interpret the widened durable record.
// 57: Parked safe-boundary resume plans preserve feature-disabled, missing
// continuation authority, and unavailable safety-observation reasons.
// Older peers collapse these causes and can misclassify recovery failures.
// 56: Failed Turn snapshots preserve the structured context-budget exhaustion
// detail. Epoch-55 peers reject the optional field on the closed snapshot shape.
// 55: Local owners can atomically revoke every credential for one access
// principal, closing pairing-finalize races that credential-by-ID revocation cannot.
// 54: Client-bound pairing candidates restrict pre-claim authority and bind
// their durable credential to the claiming Client identity; it is also reserved
// by concurrent protocol changes in #3390.
// 53: Message admission answers `turn.message.submit` with an explicit
// disposition, and queued Messages can be proven cancelled. Older peers read the
// answer as a bare acknowledgement and cannot reconcile their own projection.
// 52: Session subscriptions can forward durable steering-message echoes and
// preserve their identity across queue and transcript projection.
// Older peers cannot safely de-duplicate the two authoritative paths.
// 51: WorkHub exposes bounded coordination candidates and admits only typed
// actions through the deterministic Runtime Host Action Gate.
// 50: WorkHub can append durable coordination summaries and admit tool-free
// answers through its reserved Coordination Session authority.
// 49: WorkHub resolves one durable Coordination Session per Runtime Host.
// Older peers do not know the operation or the hidden Session role.
// 48: Session branch creation accepts an explicit Side Conversation intent.
// Older peers reject the strict input shape or cannot apply its snapshot semantics.
// 47: Project registration can carry an explicit location preference. Epoch-46
// hosts reject that optional field on the closed registration input.
// 46: Queued message content can be edited in place (queue.entry.update).
// 45: Connection onboarding inputs require `baseUrl` and `connectionId`, and
// results can carry the `base_url_not_configured` / `connection_not_found`
// rejections. Older peers reject all of these shapes.
// 44: Session continuity and inspection stop carrying the retired Session
// last-used timestamp. Older peers reject those strict projection shapes.
// 43: Session tool-start events correlate hidden shell polls with `shellRunRef`.
// Older peers reject that added closed-union field.
// 42: Turn provider retry progress adds `provider_capacity`. Older peers reject
// that strict retry-reason enum value, so mixed versions must fail handshake.
// 41: Context compaction returns a typed terminal outcome on both Turn
// snapshots and context.compact results. Epoch-40 peers reject these closed
// shapes after admission, so mixed peers must fail during the handshake.
// 40: The message queue gains per-entry mutation operations
// (queue.entry.promote, queue.entry.retract, queue.entries.reorder).
// 39: Client Capability tool descriptors carry trusted activity semantics and
// invocations can stream bounded progress frames.
// 38: `execute` is no longer a permission mode. Frame decoders reject it, so a
// peer that still sends it would fail mid-Session rather than at connect.
// 37: External Session catalog queries carry a search term.
// 36: Session trace inspection no longer transports aggregate TraceTotals.
// 35: Session trace inspection uses cursor pages and Session usage has its own
// invalidation domain. Older peers cannot safely exchange those frames.
// 34: ScheduledTask execution templates no longer emit `backend`. Epoch-33
// Clients require that closed-shape response field, so a newer Host must reject
// them during the handshake instead of failing on the first Automation read.
// 33: Live tool results may carry the bounded sandbox failure reason. Older
// Clients reject that closed-frame addition, so mixed peers must not connect.
// 32: `request_authorization_code` leaves the OAuth presentation wire. An older
// Client still offers it and an older Host still asks for it, and neither side
// can carry the authorization code the other expects.
// 31: `claude-subscription` leaves `OAUTH_LOGIN_PROVIDERS` and the
// `oauth.account.usage.fetch` operation is removed with the provider that
// needed its client identity. An older peer still offers both.
// 30: Access credential pairing adds prepare/finalize operations. Older Hosts
// cannot complete the staged credential handoff used by managed onboarding.
// 29: `goal.arm` is a new wire operation. An older Host decodes it as unknown
// and tears the connection down, so the pair must be refused up front.
// 28: Relay model profiles carry the Fast service-tier declaration. Older
// peers cannot safely preserve that Runtime Policy field.
// 27: Runtime Policy carries the Host-owned shell preference used by tool,
// PTY, and prompt composition. Older peers cannot safely preserve that field.
// Transcript pages amortize storage and network round trips with a 512 KiB raw
// payload. Base64 expansion plus the bounded fragment envelope must still fit in
// one transport message; narrower domains retain their own encoded limits.
export const RUNTIME_HOST_MAX_MESSAGE_BYTES = 768 * 1024;
export const RUNTIME_HOST_MAX_IN_FLIGHT_DOMAIN_REQUESTS = 64;
export const INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID = 'maka.interactive' as const;
declare const encodedProtocolMessageBrand: unique symbol;
export type EncodedProtocolMessage = Buffer & {
readonly [encodedProtocolMessageBrand]: true;
};
export interface ProtocolRange {
min: number;
max: number;
}
export interface ClientHello {
kind: 'hello';
clientInstanceId: string;
protocolMin: number;
protocolMax: number;
compatibilityEpoch: number;
compositionId: string;
generation?: string;
takeover?: { expectedHostEpoch: string };
/** Opt in before a Host adds maintenance evidence to the strict activity record. */
activitySnapshotVersion?: 2;
}
export interface HostAccepted {
kind: 'accepted';
rootId: string;
hostEpoch: string;
connectionId: string;
selectedProtocol: number;
compatibilityEpoch: number;
compositionId: string;
compositionRevision: string;
state: Exclude<HostLifecycleState, 'draining'>;
cooperativeHandoff?: true;
}
export interface HostIncompatible {
kind: 'incompatible';
hostEpoch: string;
protocolMin: number;
protocolMax: number;
compatibilityEpoch: number;
compositionId: string;
compositionRevision: string;
generation?: string;
state: HostLifecycleState;
replacement: 'blocked_by_residency' | 'wait_for_idle_exit';
activity?: HostActivitySnapshot;
}
export interface HostDraining {
kind: 'draining';
hostEpoch: string;
compositionId: string;
compositionRevision: string;
}
export type HostHandshakeResult = HostAccepted | HostIncompatible | HostDraining;
export type ClientFrame = ClientHello | RequestFrame | ClientCapabilityClientFrame;
export type HostFrame =
| HostHandshakeResult
| ResponseFrame
| SubscriptionFrame
| ClientCapabilityHostFrame
| ConfigurationChangedFrame
| ConnectionCatalogChangedFrame
| ProjectCatalogChangedFrame
| SessionCatalogChangedFrame
| ScheduledTaskChangedFrame;
export interface HostRegistration {
kind: 'maka-runtime-host';
schemaVersion: typeof RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION;
rootId: string;
hostEpoch: string;
endpoint: string;
websocketEndpoints?: readonly string[];
protocolMin: number;
protocolMax: number;
compatibilityEpoch: number;
compositionId: string;
compositionRevision: string;
lifecycleMode?: 'ephemeral' | 'service';
generation?: string;
state: HostLifecycleState;
pid: number;
createdAt: string;
}
export function negotiateProtocol(client: ProtocolRange, host: ProtocolRange): number | undefined {
validateProtocolRange(client);
validateProtocolRange(host);
const selected = Math.min(client.max, host.max);
return selected >= Math.max(client.min, host.min) ? selected : undefined;
}
export function validateProtocolRange(range: ProtocolRange): void {
if (
!Number.isSafeInteger(range.min) ||
!Number.isSafeInteger(range.max) ||
range.min < 0 ||
range.max < range.min
) {
throw invalidProtocolFrame('Invalid protocol range');
}
}
export function requireClientInstanceId(value: unknown): string {
return requireId(value, 'clientInstanceId');
}
export function requireHostGeneration(value: unknown): string {
return requireId(value, 'generation');
}
export function decodeClientFrame(value: unknown): ClientFrame {
const frame = requireRecord(value, 'client frame');
if (frame.kind === 'hello') {
const protocolMin = requireProtocolVersion(frame.protocolMin, 'protocolMin');
const protocolMax = requireProtocolVersion(frame.protocolMax, 'protocolMax');
validateProtocolRange({ min: protocolMin, max: protocolMax });
const generation =
frame.generation === undefined ? undefined : requireHostGeneration(frame.generation);
const takeover = decodeTakeover(frame.takeover);
if (takeover !== undefined && generation === undefined) {
throw invalidProtocolFrame('Runtime Host takeover requires a generation');
}
return {
kind: 'hello',
...(frame.activitySnapshotVersion === 2 ? { activitySnapshotVersion: 2 as const } : {}),
clientInstanceId: requireClientInstanceId(frame.clientInstanceId),
protocolMin,
protocolMax,
compatibilityEpoch: decodeCompatibilityEpoch(frame.compatibilityEpoch),
compositionId: decodeCompositionId(frame.compositionId),
...(generation === undefined ? {} : { generation }),
...(takeover === undefined ? {} : { takeover }),
} satisfies ClientHello;
}
if (isClientCapabilityClientFrameKind(frame.kind)) {
return decodeClientCapabilityClientFrame(frame);
}
return decodeRequestFrame(frame);
}
export function decodeHostFrame(value: unknown): HostFrame {
const frame = requireRecord(value, 'host frame');
if (frame.kind === 'accepted') {
if (frame.cooperativeHandoff !== undefined && frame.cooperativeHandoff !== true) {
throw invalidProtocolFrame('Invalid Runtime Host cooperative handoff capability');
}
return {
kind: 'accepted',
...(frame.cooperativeHandoff === true ? { cooperativeHandoff: true as const } : {}),
rootId: requireHostRootId(frame.rootId),
hostEpoch: requireId(frame.hostEpoch, 'hostEpoch'),
connectionId: requireId(frame.connectionId, 'connectionId'),
selectedProtocol: requireProtocolVersion(frame.selectedProtocol, 'selectedProtocol'),
compatibilityEpoch: decodeCompatibilityEpoch(frame.compatibilityEpoch),
compositionId: decodeCompositionId(frame.compositionId),
compositionRevision: decodeCompositionRevision(frame.compositionRevision),
state: requireAcceptedState(frame.state),
} satisfies HostAccepted;
}
if (frame.kind === 'incompatible') {
const protocolMin = requireProtocolVersion(frame.protocolMin, 'protocolMin');
const protocolMax = requireProtocolVersion(frame.protocolMax, 'protocolMax');
validateProtocolRange({ min: protocolMin, max: protocolMax });
return {
kind: 'incompatible',
hostEpoch: requireId(frame.hostEpoch, 'hostEpoch'),
protocolMin,
protocolMax,
compatibilityEpoch: decodeCompatibilityEpoch(frame.compatibilityEpoch),
compositionId: decodeCompositionId(frame.compositionId),
compositionRevision: decodeCompositionRevision(frame.compositionRevision),
...(frame.generation === undefined
? {}
: { generation: requireHostGeneration(frame.generation) }),
state: requireHostLifecycleState(frame.state),
replacement: requireReplacement(frame.replacement),
...(frame.activity === undefined
? {}
: { activity: decodeHostActivitySnapshot(frame.activity) }),
} satisfies HostIncompatible;
}
if (frame.kind === 'draining') {
return {
kind: 'draining',
hostEpoch: requireId(frame.hostEpoch, 'hostEpoch'),
compositionId: decodeCompositionId(frame.compositionId),
compositionRevision: decodeCompositionRevision(frame.compositionRevision),
};
}
if (isSubscriptionFrameKind(frame.kind)) return decodeSubscriptionFrame(frame);
if (isClientCapabilityHostFrameKind(frame.kind)) {
return decodeClientCapabilityHostFrame(frame);
}
if (frame.kind === 'configuration.changed') return decodeConfigurationChangedFrame(frame);
if (frame.kind === 'connection.catalog.changed') {
return decodeConnectionCatalogChangedFrame(frame);
}
if (frame.kind === 'project.catalog.changed') return decodeProjectCatalogChangedFrame(frame);
if (frame.kind === 'session.catalog.changed') return decodeSessionCatalogChangedFrame(frame);
if (frame.kind === 'scheduled-task.changed') return decodeScheduledTaskChangedFrame(frame);
return decodeResponseFrame(frame);
}
export function decodeHostRegistration(value: unknown): HostRegistration {
const registration = requireRecord(value, 'host registration');
if (registration.kind !== 'maka-runtime-host') {
throw invalidProtocolFrame('Invalid registration kind');
}
if (registration.schemaVersion !== RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION) {
throw invalidProtocolFrame('Unsupported registration schema');
}
const protocolMin = requireProtocolVersion(registration.protocolMin, 'protocolMin');
const protocolMax = requireProtocolVersion(registration.protocolMax, 'protocolMax');
validateProtocolRange({ min: protocolMin, max: protocolMax });
const rootId = requireHostRootId(registration.rootId);
const websocketEndpoints = decodeRegistrationWebSocketEndpoints(registration.websocketEndpoints);
const pid = requireCount(registration.pid, 'pid');
if (pid === 0) throw invalidProtocolFrame('Invalid pid');
return {
kind: 'maka-runtime-host',
schemaVersion: RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION,
rootId,
hostEpoch: requireId(registration.hostEpoch, 'hostEpoch'),
endpoint: requireString(registration.endpoint, 'endpoint', 512),
...(websocketEndpoints === undefined ? {} : { websocketEndpoints }),
protocolMin,
protocolMax,
compatibilityEpoch:
registration.compatibilityEpoch === undefined
? 0
: requireCompatibilityEpoch(registration.compatibilityEpoch),
compositionId: decodeCompositionId(registration.compositionId),
compositionRevision: decodeCompositionRevision(registration.compositionRevision),
...(registration.lifecycleMode === undefined
? {}
: {
lifecycleMode: requireHostLifecycleMode(registration.lifecycleMode),
}),
...(registration.generation === undefined
? {}
: { generation: requireHostGeneration(registration.generation) }),
state: requireHostLifecycleState(registration.state),
pid,
createdAt: requireString(registration.createdAt, 'createdAt', 64),
};
}
function decodeRegistrationWebSocketEndpoints(value: unknown): readonly string[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.length === 0 || value.length > 4) {
throw invalidProtocolFrame('Invalid Runtime Host registration WebSocket endpoints');
}
const endpoints = value.map((entry) => {
const endpoint = requireString(entry, 'Runtime Host WebSocket endpoint', 2_048);
let url: URL;
try {
url = new URL(endpoint);
} catch {
throw invalidProtocolFrame('Invalid Runtime Host registration WebSocket endpoint');
}
if (
url.protocol !== 'ws:' ||
url.hostname !== '127.0.0.1' ||
url.username ||
url.password ||
url.port === '' ||
url.search ||
url.hash ||
!isCanonicalRuntimeHostWebSocketPath(url.pathname)
) {
throw invalidProtocolFrame('Invalid Runtime Host registration WebSocket endpoint');
}
return url.toString();
});
if (new Set(endpoints).size !== endpoints.length) {
throw invalidProtocolFrame('Duplicate Runtime Host registration WebSocket endpoint');
}
return Object.freeze(endpoints);
}
function requireHostLifecycleMode(value: unknown): 'ephemeral' | 'service' {
if (value === 'ephemeral' || value === 'service') return value;
throw invalidProtocolFrame('Invalid Runtime Host lifecycle mode');
}
function decodeTakeover(value: unknown): ClientHello['takeover'] {
if (value === undefined) return undefined;
const takeover = requireRecord(value, 'Runtime Host takeover');
return {
expectedHostEpoch: requireId(takeover.expectedHostEpoch, 'expectedHostEpoch'),
};
}
export function encodeProtocolMessage(value: ClientFrame | HostFrame): EncodedProtocolMessage {
const encoded = Buffer.from(JSON.stringify(value), 'utf8');
if (encoded.byteLength > RUNTIME_HOST_MAX_MESSAGE_BYTES) {
throw new RuntimeHostProtocolError(
'frame_too_large',
'Runtime Host message exceeds the byte limit',
);
}
return encoded as EncodedProtocolMessage;
}
function requireProtocolVersion(value: unknown, label: string): number {
if (!Number.isSafeInteger(value) || (value as number) < 0) {
throw invalidProtocolFrame(`Invalid ${label}`);
}
return value as number;
}
function requireCompatibilityEpoch(value: unknown): number {
const epoch = requireProtocolVersion(value, 'compatibilityEpoch');
if (epoch > 1_000_000) throw invalidProtocolFrame('Invalid compatibilityEpoch');
return epoch;
}
export function requireHostCompositionId(value: unknown): string {
const id = requireString(value, 'compositionId', 128);
if (!/^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(id)) {
throw invalidProtocolFrame('Invalid compositionId');
}
return id;
}
export function requireHostRootId(value: unknown): string {
const rootId = requireString(value, 'rootId', 64);
if (!/^[a-f0-9]{64}$/.test(rootId)) throw invalidProtocolFrame('Invalid rootId');
return rootId;
}
function requireCompositionRevision(value: unknown): string {
const revision = requireString(value, 'compositionRevision', 128);
if (revision.length === 0 || /[\u0000-\u001f\u007f]/u.test(revision)) {
throw invalidProtocolFrame('Invalid compositionRevision');
}
return revision;
}
function decodeCompositionId(value: unknown): string {
return value === undefined
? INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID
: requireHostCompositionId(value);
}
function decodeCompositionRevision(value: unknown): string {
return value === undefined ? 'legacy' : requireCompositionRevision(value);
}
function decodeCompatibilityEpoch(value: unknown): number {
// Epoch 0 represents peers and registrations that do not publish this field.
return value === undefined ? 0 : requireCompatibilityEpoch(value);
}
function requireAcceptedState(value: unknown): Exclude<HostLifecycleState, 'draining'> {
const state = requireHostLifecycleState(value);
if (state === 'draining') throw invalidProtocolFrame('Accepted Host cannot be draining');
return state;
}
function requireReplacement(value: unknown): HostIncompatible['replacement'] {
if (value === 'blocked_by_residency' || value === 'wait_for_idle_exit') return value;
throw invalidProtocolFrame('Invalid replacement disposition');
}