diff --git a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts index 3da977f0a2..b5262a3c69 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-collaboration-ipc-main.test.ts @@ -190,6 +190,7 @@ test('treats an unavailable collaboration authority as an empty background inbox assert.deepEqual(await query({} as Parameters[0]), { canRequestTurns: false, requests: [], + authorityUnavailable: true, }); await assert.rejects( query({} as Parameters[0], 'session-1'), diff --git a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts index 7320f3e868..1d59ba10d8 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-desktop-manager.test.ts @@ -110,6 +110,20 @@ test('replaces a disconnected Runtime Host generation', { timeout: 10_000 }, asy assert.equal(second.closeCalls, 1); }); +test('publishes the Runtime Host collaboration capability with its identity', async () => { + const current = candidateHarness(); + (current.candidate.client as unknown as { + status: () => Promise<{ collaborationAuthority: boolean }>; + }).status = async () => ({ collaborationAuthority: false }); + const owner = await startRuntimeHostDesktopManager({} as DesktopRuntimeHostCandidateStartInput, { + startCandidate: async () => ready(current.candidate), + }); + + assert.equal(owner.current()?.collaborationAuthority, false); + assert.equal(owner.entries()[0]?.collaborationAuthority, false); + await owner.close(); +}); + test('quiesces reconnect and waits for the Host process before update install', async () => { const current = candidateHarness({ disconnectOnPrepare: true }); const replacement = candidateHarness(); diff --git a/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts b/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts index 267504c866..07fc4790c0 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-turn-request-inbox-preload.test.ts @@ -20,7 +20,59 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; -import { collectAvailablePendingTurnRequests } from '../../preload/runtime-host-turn-request-inbox.js'; +import { + collectAvailablePendingTurnRequests, + collectPendingTurnRequestsWithCapabilityCache, + retainRuntimeHostCollaborationAuthority, + selectRuntimeHostCollaborationScopes, +} from '../../preload/runtime-host-turn-request-inbox.js'; + +test('retains a learned unavailable capability when a legacy identity omits it', () => { + assert.equal(retainRuntimeHostCollaborationAuthority(undefined, false), false); + assert.equal(retainRuntimeHostCollaborationAuthority(true, false), true); +}); + +test('caches an unavailable legacy Host across polling calls', async () => { + const scope = { hostId: 'legacy' }; + let authority: boolean | undefined; + let queryCalls = 0; + const poll = () => + collectPendingTurnRequestsWithCapabilityCache( + [scope], + () => authority, + async () => { + queryCalls += 1; + return { requests: [], authorityUnavailable: true }; + }, + () => { + authority = false; + }, + ); + + assert.deepEqual(await poll(), []); + assert.equal(authority, false); + assert.deepEqual(await poll(), []); + assert.equal(queryCalls, 1); +}); + +test('skips an Owner Host that explicitly lacks collaboration authority', () => { + const scopes = selectRuntimeHostCollaborationScopes([ + { hostId: 'local', collaborationAuthority: false }, + { hostId: 'remote', collaborationAuthority: true }, + { hostId: 'legacy' }, + ]); + + assert.deepEqual(scopes.map(({ hostId }) => hostId), ['remote', 'legacy']); +}); + +test('keeps transiently unavailable collaboration inboxes retryable', async () => { + const requests = await collectAvailablePendingTurnRequests([ + Promise.reject(new Error('connection lost while polling')), + Promise.resolve([request('available', '2026-09-01T00:00:01.000Z')]), + ]); + + assert.deepEqual(requests.map(({ requestId }) => requestId), ['available']); +}); function request(requestId: string, createdAt: string): SessionTurnAccessRequest { return { requestId, createdAt } as SessionTurnAccessRequest; diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 74e68af3ee..468eff81e9 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1095,6 +1095,9 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( profileName: state.target.profile.name, profileKind: state.target.profile.kind, profileAccess: runtimeHostProfileAccess(state.target.profile), + ...(state.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: state.collaborationAuthority }), ...(hostId ? { hostId } : {}), readiness: state.readiness, isDefault: @@ -1135,6 +1138,9 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( profileName: state.target.profile.name, profileKind: state.target.profile.kind, profileAccess: runtimeHostProfileAccess(state.target.profile), + ...(state.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: state.collaborationAuthority }), ...(hostId ? { hostId } : {}), readiness: "unavailable", isDefault: @@ -1159,6 +1165,9 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( profileName: state?.target.profile.name ?? profileId, profileKind: state?.target.profile.kind ?? "remote", profileAccess: state ? runtimeHostProfileAccess(state.target.profile) : "owner", + ...(state?.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: state.collaborationAuthority }), ...(state?.readiness === "ready" ? { hostId: state.candidate.client.hostId } : state?.readiness !== "unavailable" && state && "hostId" in state && state.hostId @@ -1750,6 +1759,7 @@ function registerPersistentClientIpc(): void { target: ResolvedRuntimeHostProfile, readiness: 'ready' | 'reconnecting', hostId: string, + collaborationAuthority?: boolean, ): DesktopRuntimeHostIdentity => ({ hostId, targetEpoch: epoch, @@ -1757,6 +1767,7 @@ function registerPersistentClientIpc(): void { profileName: target.profile.name, profileKind: target.profile.kind, profileAccess: runtimeHostProfileAccess(target.profile), + ...(collaborationAuthority === undefined ? {} : { collaborationAuthority }), readiness, }); ipcMain.handle("runtime-host:activeIdentity", () => { @@ -1769,6 +1780,7 @@ function registerPersistentClientIpc(): void { current.target, current.readiness, current.hostId, + current.collaborationAuthority, ); }); ipcMain.handle("runtime-host:identities", () => @@ -1777,7 +1789,13 @@ function registerPersistentClientIpc(): void { const hostId = state.readiness === "ready" ? state.candidate.client.hostId : state.hostId; if (!hostId) return []; return [ - projectRuntimeHostIdentity(state.epoch, state.target, state.readiness, hostId), + projectRuntimeHostIdentity( + state.epoch, + state.target, + state.readiness, + hostId, + state.collaborationAuthority, + ), ]; }), ); diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index 5a39dba44b..c25ff252a2 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -301,8 +301,8 @@ export class DesktopRuntimeHostClient { return this.#connectionClosed || this.#closeTask ? 'unavailable' : 'ready'; } - status(): Promise { - return this.connection.status(); + status(timeoutMs?: number): Promise { + return this.connection.status(timeoutMs); } finalizeAccessCredential( diff --git a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts index 502d5b9b23..8898165129 100644 --- a/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-collaboration-ipc-main.ts @@ -19,6 +19,7 @@ import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; +import type { CollaborationTurnRequestQueryResult } from '@maka/runtime-host/protocol'; import { encodeDesktopCollaborationInvitation, type DesktopCollaborationConnectionTarget, @@ -101,7 +102,11 @@ export function registerRuntimeHostCollaborationIpc( return await client.queryCollaborationTurnRequests(requestedSessionId); } catch (error) { if (requestedSessionId === undefined && isCollaborationInboxUnavailable(error)) { - return { canRequestTurns: false, requests: [] }; + return { + canRequestTurns: false, + requests: [], + authorityUnavailable: true, + } satisfies CollaborationTurnRequestQueryResult & { authorityUnavailable: true }; } throw error; } diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 150e760454..6134dde9ae 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -104,6 +104,7 @@ export interface RuntimeHostDesktopTargetSnapshot { readonly target: ResolvedRuntimeHostProfile; readonly readiness: 'ready' | 'reconnecting'; readonly candidate?: DesktopRuntimeHostCandidate; + readonly collaborationAuthority?: boolean; } export type RuntimeHostDesktopTargetState = @@ -112,18 +113,21 @@ export type RuntimeHostDesktopTargetState = readonly target: ResolvedRuntimeHostProfile; readonly readiness: 'connecting' | 'reconnecting'; readonly hostId?: string; + readonly collaborationAuthority?: boolean; } | { readonly epoch: string; readonly target: ResolvedRuntimeHostProfile; readonly readiness: 'ready'; readonly candidate: DesktopRuntimeHostCandidate; + readonly collaborationAuthority?: boolean; } | { readonly epoch: string; readonly target: ResolvedRuntimeHostProfile; readonly readiness: 'unavailable'; readonly hostId?: string; + readonly collaborationAuthority?: boolean; readonly error: Error; }; @@ -213,6 +217,7 @@ interface DesktopRuntimeHostTargetGeneration { readonly observations: RuntimeHostSessionObservationRegistry; state: RuntimeHostDesktopTargetState; hostId?: string; + collaborationAuthority?: boolean; lifecycle?: RuntimeHostReconnectLifecycle; unsubscribeLifecycle?: () => void; unsubscribeRoutes?: () => void; @@ -467,12 +472,20 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ...(target.hostId ? { hostId: target.hostId } : {}), target: target.target, readiness: candidate ? 'ready' : 'reconnecting', + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), ...(candidate ? { candidate } : {}), }; } entries(): readonly RuntimeHostDesktopTargetState[] { - return [...this.#targets.values()].map((target) => target.state); + return [...this.#targets.values()].map((target) => ({ + ...target.state, + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), + })); } ownsScope(scope: { readonly hostId: string; readonly targetEpoch: string }): boolean { @@ -971,6 +984,12 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { // replay one-shot join progress. onConnectionPhase: (phase) => onConnectionPhase?.(phase), ...(refreshPeerRoutes ? {} : { refreshPeerRoutes: false }), + onHostStatus: (status) => { + if (status.collaborationAuthority !== undefined) { + target.collaborationAuthority = status.collaborationAuthority; + } + target.input.onHostStatus?.(status); + }, signal, ...(takeoverHostEpoch === undefined ? {} : { takeoverHostEpoch }), }, @@ -983,6 +1002,13 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { throw error; } if (result.kind === 'ready') { + const status = result.candidate.client.status; + if (typeof status === 'function') { + const observed = await status.call(result.candidate.client, 5_000).catch(() => undefined); + if (observed?.collaborationAuthority !== undefined) { + target.collaborationAuthority = observed.collaborationAuthority; + } + } target.hostId = result.candidate.client.hostId; const previous = target.lastCandidate; const retainedOwnedProcess = @@ -1201,12 +1227,18 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { target: target.target, readiness: 'ready', candidate, + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), } : { epoch: target.epoch, target: target.target, readiness: 'reconnecting', ...(target.hostId ? { hostId: target.hostId } : {}), + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), }, ); }); @@ -1219,12 +1251,18 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { target: target.target, readiness: 'ready', candidate, + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), } : { epoch: target.epoch, target: target.target, readiness: 'reconnecting', ...(target.hostId ? { hostId: target.hostId } : {}), + ...(target.collaborationAuthority === undefined + ? {} + : { collaborationAuthority: target.collaborationAuthority }), }, ); } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index d68e5c75c2..89ab3ddba9 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -451,6 +451,7 @@ export interface DesktopRuntimeHostProfileChangedEvent { readonly profileName: string; readonly profileKind: RuntimeHostProfileKind; readonly profileAccess: RuntimeHostProfileAccess; + readonly collaborationAuthority?: boolean; readonly readiness: 'connecting' | 'ready' | 'reconnecting' | 'unavailable'; readonly hostId?: string; readonly isDefault: boolean; @@ -462,6 +463,7 @@ export interface DesktopRuntimeHostIdentity extends DesktopRuntimeHostRef { readonly profileName: string; readonly profileKind: RuntimeHostProfileKind; readonly profileAccess: RuntimeHostProfileAccess; + readonly collaborationAuthority?: boolean; readonly readiness: 'ready' | 'reconnecting'; } diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index b4013452f4..bb66646f9e 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -125,7 +125,10 @@ import { collectRuntimeHostSessionCatalogsWithCoverage, resolveRuntimeHostSessionCatalog, } from './runtime-host-session-catalog.js'; -import { collectAvailablePendingTurnRequests } from './runtime-host-turn-request-inbox.js'; +import { + collectPendingTurnRequestsWithCapabilityCache, + retainRuntimeHostCollaborationAuthority, +} from './runtime-host-turn-request-inbox.js'; import type { ExecutionBoundaryReadModel, SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; import type { ClientCapabilityResponse } from '@maka/core/client-capability-grant'; import type { @@ -262,6 +265,7 @@ const runtimeHostMetadata = new Map< readonly profileName: string; readonly profileKind: RuntimeHostProfileKind; readonly profileAccess: 'owner' | 'session_guest'; + readonly collaborationAuthority?: boolean; } >(); const runtimeHostSessionScopes = new Map(); @@ -277,6 +281,15 @@ function runtimeHostMetadataFor(scope: DesktopTargetScope) { return runtimeHostMetadata.get(runtimeHostScopeKey(scope)); } +function markRuntimeHostCollaborationUnavailable(scope: DesktopTargetScope): void { + const metadata = runtimeHostMetadataFor(scope); + if (!metadata || metadata.collaborationAuthority === false) return; + runtimeHostMetadata.set(runtimeHostScopeKey(scope), { + ...metadata, + collaborationAuthority: false, + }); +} + function recordRuntimeHostSessionScope(scope: DesktopTargetScope, sessionId: string): string { const projected = desktopSessionKey({ hostId: scope.hostId, sessionId }); runtimeHostSessionScopes.set(projected, runtimeHostScopeKey(scope)); @@ -309,6 +322,13 @@ ipcRenderer.on( } } if (nextScope && nextScopeKey) { + const previousMetadata = runtimeHostMetadata.get(nextScopeKey); + const collaborationAuthority = retainRuntimeHostCollaborationAuthority( + typeof change.collaborationAuthority === 'boolean' + ? change.collaborationAuthority + : undefined, + previousMetadata?.collaborationAuthority, + ); runtimeHostScopes.set(nextScopeKey, nextScope); runtimeHostProfiles.set(change.profileId, nextScopeKey); runtimeHostMetadata.set(nextScopeKey, { @@ -316,6 +336,7 @@ ipcRenderer.on( profileName: change.profileName, profileKind: change.profileKind, profileAccess: change.profileAccess, + ...(collaborationAuthority === undefined ? {} : { collaborationAuthority }), }); if (change.isDefault) activeRuntimeHost = nextScope; } else if (change.isDefault) { @@ -343,6 +364,7 @@ function recordRuntimeHostIdentity(value: unknown): { profileName?: unknown; profileKind?: unknown; profileAccess?: unknown; + collaborationAuthority?: unknown; readiness?: unknown; }; if ( @@ -350,11 +372,18 @@ function recordRuntimeHostIdentity(value: unknown): { typeof metadata.profileName !== 'string' || !isRuntimeHostProfileKind(metadata.profileKind) || (metadata.profileAccess !== 'owner' && metadata.profileAccess !== 'session_guest') || + (metadata.collaborationAuthority !== undefined && + typeof metadata.collaborationAuthority !== 'boolean') || (metadata.readiness !== 'ready' && metadata.readiness !== 'reconnecting') ) { throw new Error('Desktop Runtime Host identity is invalid'); } const scopeKey = runtimeHostScopeKey(scope); + const previousMetadata = runtimeHostMetadata.get(scopeKey); + const collaborationAuthority = retainRuntimeHostCollaborationAuthority( + metadata.collaborationAuthority as boolean | undefined, + previousMetadata?.collaborationAuthority, + ); runtimeHostScopes.set(scopeKey, scope); runtimeHostProfiles.set(metadata.profileId, scopeKey); runtimeHostMetadata.set(scopeKey, { @@ -362,6 +391,7 @@ function recordRuntimeHostIdentity(value: unknown): { profileName: metadata.profileName, profileKind: metadata.profileKind, profileAccess: metadata.profileAccess, + ...(collaborationAuthority === undefined ? {} : { collaborationAuthority }), }); return { scope, readiness: metadata.readiness }; } @@ -1364,22 +1394,28 @@ const makaBridge = { const scopes = (await runtimeHostScopeList()).filter( (scope) => runtimeHostMetadataFor(scope)?.profileAccess === 'owner', ); - return collectAvailablePendingTurnRequests( - scopes.map(async (scope) => { + return collectPendingTurnRequestsWithCapabilityCache( + scopes, + (scope) => runtimeHostMetadataFor(scope)?.collaborationAuthority, + async (scope) => { const result = await ipcRenderer.invoke( 'session-collaboration:turn-request:query', scope, - ) as CollaborationTurnRequestQueryResult; - return result.requests - .filter((request) => request.state.kind === 'pending') - .map((request): SessionTurnAccessRequest => ({ - ...request, - intent: { - ...request.intent, - sessionId: recordRuntimeHostSessionScope(scope, request.intent.sessionId), - }, - })); - }), + ) as CollaborationTurnRequestQueryResult & { authorityUnavailable?: true }; + return { + ...(result.authorityUnavailable ? { authorityUnavailable: true as const } : {}), + requests: result.requests + .filter((request) => request.state.kind === 'pending') + .map((request): SessionTurnAccessRequest => ({ + ...request, + intent: { + ...request.intent, + sessionId: recordRuntimeHostSessionScope(scope, request.intent.sessionId), + }, + })), + }; + }, + markRuntimeHostCollaborationUnavailable, ); }, async acknowledgeTurnRequest(sessionId, requestId) { diff --git a/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts b/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts index 86199b9187..3ec6cb4b18 100644 --- a/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts +++ b/apps/desktop/src/preload/runtime-host-turn-request-inbox.ts @@ -19,6 +19,45 @@ import type { SessionTurnAccessRequest } from '@maka/runtime-host/protocol'; +export interface RuntimeHostCollaborationScope { + readonly collaborationAuthority?: boolean; +} + +export interface RuntimeHostPendingTurnRequestQuery { + readonly requests: readonly SessionTurnAccessRequest[]; + readonly authorityUnavailable?: true; +} + +export function retainRuntimeHostCollaborationAuthority( + observed: boolean | undefined, + previous: boolean | undefined, +): boolean | undefined { + return observed ?? previous; +} + +/** Hosts with an explicit negative capability cannot answer collaboration queries. */ +export function selectRuntimeHostCollaborationScopes( + scopes: readonly T[], +): T[] { + return scopes.filter((scope) => scope.collaborationAuthority !== false); +} + +export async function collectPendingTurnRequestsWithCapabilityCache( + scopes: readonly T[], + collaborationAuthority: (scope: T) => boolean | undefined, + query: (scope: T) => Promise, + markAuthorityUnavailable: (scope: T) => void, +): Promise { + const eligibleScopes = scopes.filter((scope) => collaborationAuthority(scope) !== false); + return collectAvailablePendingTurnRequests( + eligibleScopes.map(async (scope) => { + const result = await query(scope); + if (result.authorityUnavailable) markAuthorityUnavailable(scope); + return result.requests; + }), + ); +} + export async function collectAvailablePendingTurnRequests( queries: readonly Promise[], ): Promise { diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index baf7270a1b..fcad2afa57 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -2170,6 +2170,19 @@ describe('Runtime Host bootstrap protocol', () => { }, }; assert.deepEqual(HOST_BOOTSTRAP_OPERATION_SPECS['host.status'].decodeOutput(status), status); + assert.equal( + HOST_BOOTSTRAP_OPERATION_SPECS['host.status'].decodeOutput({ + ...status, + collaborationAuthority: false, + }).collaborationAuthority, + false, + ); + assert.throws(() => + HOST_BOOTSTRAP_OPERATION_SPECS['host.status'].decodeOutput({ + ...status, + collaborationAuthority: 'unknown', + }), + ); assert.throws(() => HOST_BOOTSTRAP_OPERATION_SPECS['host.status'].decodeOutput({ ...status, diff --git a/packages/runtime-host/src/protocol/host-status.ts b/packages/runtime-host/src/protocol/host-status.ts index 5ec5bd51fd..aa6d7eed34 100644 --- a/packages/runtime-host/src/protocol/host-status.ts +++ b/packages/runtime-host/src/protocol/host-status.ts @@ -68,6 +68,8 @@ export interface HostStatusResult { hostEpoch: string; compositionId: string; compositionRevision: string; + /** Whether this Host can serve collaboration authority operations. */ + collaborationAuthority?: boolean; state: HostLifecycleState; connections: number; activeOperations: number; @@ -126,6 +128,7 @@ function decodeHostStatusResult(value: unknown): HostStatusResult { 'hostEpoch', 'compositionId', 'compositionRevision', + ...(valueRecord.collaborationAuthority === undefined ? [] : ['collaborationAuthority']), 'state', 'connections', 'activeOperations', @@ -146,6 +149,7 @@ function decodeHostDiagnosticsResult(value: unknown): HostDiagnosticsResult { 'hostEpoch', 'compositionId', 'compositionRevision', + ...(valueRecord.collaborationAuthority === undefined ? [] : ['collaborationAuthority']), 'state', 'connections', 'activeOperations', @@ -277,6 +281,14 @@ function decodeHostStatusFields(record: Record): HostStatusResu 'Runtime Host composition revision', 128, ), + ...(record.collaborationAuthority === undefined + ? {} + : { + collaborationAuthority: requireBoolean( + record.collaborationAuthority, + 'collaborationAuthority', + ), + }), state: requireHostLifecycleState(record.state), connections: requireCount(record.connections, 'connections'), activeOperations: requireCount(record.activeOperations, 'activeOperations'), diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 9abf64b3a6..9e157354d6 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,10 @@ 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 = 105 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 106 as const; +// 106: Host status reports whether the Host exposes collaboration authority. +// Older peers use a strict status decoder and cannot safely accept this new +// capability field when deciding whether collaboration polling is supported. // 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. diff --git a/packages/runtime-host/src/server/host-kernel.ts b/packages/runtime-host/src/server/host-kernel.ts index 9655398dda..992e6a65f9 100644 --- a/packages/runtime-host/src/server/host-kernel.ts +++ b/packages/runtime-host/src/server/host-kernel.ts @@ -839,6 +839,7 @@ export class RuntimeHostKernel { hostEpoch: this.hostEpoch, compositionId: this.compositionDescriptor.id, compositionRevision: this.compositionDescriptor.revision, + collaborationAuthority: this.#options.accessAuthority !== undefined, state: this.#state, connections: this.#acceptedTransports.size, activeOperations: this.#activeOperations,