import { EnvironmentAuthorizationError, clientRpcRequiredScopes, authScopeRequiredResponse, type EnvironmentId, type ClientGuardedRpcTag, ORCHESTRATION_V2_WS_METHODS, WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { RpcClientError } from "effect/rpc"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; export class EnvironmentRpcUnavailableError extends Schema.TaggedError()( "EnvironmentRpcUnavailableError", { environmentId: Schema.String, message: Schema.String, }, ) {} export interface EnvironmentRpcRequestObservation { readonly environmentId: string; readonly environmentLabel: string; readonly method: string; } export class EnvironmentRpcRequestObserver extends Context.Reference<{ readonly observe: ( request: EnvironmentRpcRequestObservation, ) => Effect.Effect>; }>("@t3tools/client-runtime/rpc/EnvironmentRpcRequestObserver", { defaultValue: () => ({ observe: () => Effect.succeed(Effect.void), }), }) {} export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; type RpcMethod = WsRpcProtocolClient[TTag]; export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.providerAuthSubscribe | typeof WS_METHODS.providerInstallSubscribe | typeof ORCHESTRATION_V2_WS_METHODS.subscribeShell | typeof ORCHESTRATION_V2_WS_METHODS.subscribeThread | typeof ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput | typeof ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion | typeof ORCHESTRATION_V2_WS_METHODS.searchThreadStream | typeof WS_METHODS.subscribeAuthAccess | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe | typeof WS_METHODS.serverGetStorageCleanupReport | typeof WS_METHODS.serverGetDatabaseStorage | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeDeviceState | typeof WS_METHODS.subscribeResourceTelemetry | typeof WS_METHODS.pullRequestsSubscribeRefreshes | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.subscribeVcsBranch | typeof WS_METHODS.subscribeWorktreeSetup | typeof WS_METHODS.subscribeProjectClones | typeof WS_METHODS.terminalAttach | typeof WS_METHODS.terminalObserve; export type EnvironmentStreamCommandRpcTag = | typeof WS_METHODS.chatGptHandoffSubscribe | typeof WS_METHODS.cloudInstallRelayClient | typeof WS_METHODS.serverUpdateServerWithProgress | typeof WS_METHODS.gitRunStackedAction; export type EnvironmentStreamRpcTag = | EnvironmentSubscriptionRpcTag | EnvironmentStreamCommandRpcTag; export type EnvironmentUnaryRpcTag = Exclude; export interface EnvironmentRpcSubscriptionObservation { readonly environmentId: string; readonly method: EnvironmentSubscriptionRpcTag; readonly input: unknown; } export class EnvironmentRpcSubscriptionObserver extends Context.Reference<{ readonly observe: ( subscription: EnvironmentRpcSubscriptionObservation, ) => Effect.Effect>; }>("@t3tools/client-runtime/rpc/EnvironmentRpcSubscriptionObserver", { defaultValue: () => ({ observe: () => Effect.succeed(Effect.void), }), }) {} export const isRpcClientError = Schema.is(RpcClientError.RpcClientError); const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); /** A subscription never retries an authorization failure on its own. */ export const isAuthorizationFailure = (cause: Cause.Cause) => cause.reasons.some( (reason) => reason._tag === "Fail" && isEnvironmentAuthorizationError(reason.error), ); const MAX_EXPECTED_FAILURE_RETRY_DELAY_MS = 30_000; export const subscriptionRetryDelay = (base: Duration.Input, retries: number) => Duration.millis( Math.min(Duration.toMillis(base) * 2 ** retries, MAX_EXPECTED_FAILURE_RETRY_DELAY_MS), ); export type EnvironmentRpcInput = Parameters>[0]; export type EnvironmentRpcSuccess = RpcMethod extends (input: any, options?: any) => Effect.Effect ? A : never; export type EnvironmentRpcFailure = RpcMethod extends (input: any, options?: any) => Effect.Effect ? E : never; export type EnvironmentRpcStreamValue = RpcMethod extends (input: any, options?: any) => Stream.Stream ? A : never; export type EnvironmentRpcStreamFailure = RpcMethod extends (input: any, options?: any) => Stream.Stream ? E : never; export const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; return yield* SubscriptionRef.get(supervisor.session).pipe( Effect.flatMap( Option.match({ onNone: () => Effect.fail( new EnvironmentRpcUnavailableError({ environmentId: supervisor.target.environmentId, message: `${supervisor.target.label} is not connected.`, }), ), onSome: Effect.succeed, }), ), ); }); export const getInitialServerConfig = Effect.fn("EnvironmentRpc.getInitialServerConfig")( function* () { const session = yield* currentSession(); return yield* session.initialConfig; }, ); /** Installed by the shared command boundary. A missing boundary denies protected writes. */ export class RpcPermissionGuard extends Context.Reference<{ readonly authorize: ( environmentId: EnvironmentId, method: string, input: unknown, ) => Effect.Effect; }>("@t3tools/client-runtime/rpc/RpcPermissionGuard", { defaultValue: () => ({ authorize: (_environmentId, method, input) => { const scope = clientRpcRequiredScopes(method, input)[0]; return scope === undefined ? Effect.void : Effect.fail( new EnvironmentAuthorizationError({ ...authScopeRequiredResponse(scope), message: `This connection requires ${scope}.`, }), ); }, }), }) {} const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( method: string, input: unknown, ) { if (clientRpcRequiredScopes(method, input).length === 0) return; const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; const guard = yield* RpcPermissionGuard; yield* guard.authorize(supervisor.target.environmentId, method, input); }); export const requestInSession = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, >(session: RpcSession, tag: TTag, input: EnvironmentRpcInput) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; yield* Effect.annotateCurrentSpan({ "environment.id": supervisor.target.environmentId, "rpc.method": tag, }); yield* authorizeRequest(tag, input); const observer = yield* EnvironmentRpcRequestObserver; const method = session.client[tag] as ( input: EnvironmentRpcInput, ) => Effect.Effect, EnvironmentRpcFailure>; const completeObservation = yield* observer.observe({ environmentId: supervisor.target.environmentId, environmentLabel: supervisor.target.label, method: tag, }); return yield* method(input).pipe(Effect.ensuring(completeObservation)); }); // The session-bound request owns the existing EnvironmentRpc.request span. export const requestGuarded = Effect.fnUntraced(function* ( tag: TTag, input: EnvironmentRpcInput, ) { return yield* requestInSession(yield* currentSession(), tag, input); }); export function runStreamGuarded( tag: TTag, input: EnvironmentRpcInput, ): Stream.Stream< EnvironmentRpcStreamValue, | EnvironmentRpcStreamFailure | EnvironmentRpcUnavailableError | EnvironmentAuthorizationError, EnvironmentSupervisor.EnvironmentSupervisor > { return Stream.unwrap( currentSession().pipe(Effect.map((session) => runStreamInSession(session, tag, input))), ); } export function runStreamInSession( session: RpcSession, tag: TTag, input: EnvironmentRpcInput, ): Stream.Stream< EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure | EnvironmentAuthorizationError, EnvironmentSupervisor.EnvironmentSupervisor > { const method = session.client[tag] as ( input: EnvironmentRpcInput, ) => Stream.Stream, EnvironmentRpcStreamFailure>; return Stream.unwrap(authorizeRequest(tag, input).pipe(Effect.map(() => method(input)))).pipe( Stream.withSpan("EnvironmentRpc.runStream", { attributes: { "rpc.method": tag }, }), ); } interface SubscriptionOptions { /** Reports defects; the subscription then waits for the next session or resubscribe. */ readonly onDefect?: ( cause: Cause.Cause>, ) => Effect.Effect; readonly onExpectedFailure?: ( cause: Cause.Cause>, ) => Effect.Effect; readonly retryExpectedFailureAfter?: Duration.Input; readonly resubscribe?: Stream.Stream; } function subscribeDynamicMapped( tag: TTag, makeInput: (session: RpcSession) => Effect.Effect>, mapStream: ( session: RpcSession, stream: Stream.Stream, EnvironmentRpcStreamFailure>, ) => Stream.Stream>, options?: SubscriptionOptions, ): Stream.Stream< A, EnvironmentRpcStreamFailure, EnvironmentSupervisor.EnvironmentSupervisor > { return Stream.unwrap( Effect.gen(function* () { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; const observer = yield* EnvironmentRpcSubscriptionObserver; const sessionChanges = SubscriptionRef.changes(supervisor.session); const sessions = options?.resubscribe === undefined ? sessionChanges : Stream.merge( sessionChanges, options.resubscribe.pipe( Stream.mapEffect(() => SubscriptionRef.get(supervisor.session)), ), ); return sessions.pipe( Stream.switchMap( Option.match({ onNone: () => Stream.empty, onSome: (session) => { const method = ( tag === WS_METHODS.subscribeServerConfig ? session.subscribeServerConfig : session.client[tag] ) as ( input: EnvironmentRpcInput, ) => Stream.Stream< EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure >; let expectedFailureRetries = 0; const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( Effect.gen(function* () { const input = yield* makeInput(session); const completeObservation = yield* observer.observe({ environmentId: supervisor.target.environmentId, method: tag, input, }); const stream = mapStream( session, Stream.suspend(() => method(input)), ).pipe( Stream.onFirst(() => Effect.sync(() => { expectedFailureRetries = 0; }), ), ); return stream.pipe(Stream.ensuring(completeObservation)); }), ).pipe( Stream.tapCause((cause) => options?.onDefect !== undefined && cause.reasons.some( (reason) => reason._tag === "Die" || (reason._tag === "Fail" && isRpcClientError(reason.error) && reason.error.reason._tag === "RpcClientDefect"), ) ? options.onDefect(cause) : Effect.void, ), Stream.catchCause((cause) => { const hasOnlyExpectedFailures = cause.reasons.length > 0 && cause.reasons.every((reason) => reason._tag === "Fail"); const isTransportFailure = hasOnlyExpectedFailures && cause.reasons.every( (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), ); if (isTransportFailure) { return Stream.fromEffect( Effect.logWarning( "Durable RPC subscription lost its transport; waiting for the next session.", { cause: Cause.pretty(cause), method: tag, environmentId: supervisor.target.environmentId, }, ), ).pipe(Stream.drain); } if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( Stream.drain, ); if ( options.retryExpectedFailureAfter === undefined || isAuthorizationFailure(cause) ) { return handled; } const retryDelay = subscriptionRetryDelay( options.retryExpectedFailureAfter, expectedFailureRetries, ); expectedFailureRetries += 1; return handled.pipe( Stream.concat( Stream.fromEffect(Effect.sleep(retryDelay)).pipe(Stream.drain), ), Stream.concat(subscribeToSession()), ); } // A reported defect parks until the next session or resubscribe. Failing would // end the whole subscription, so a mounted screen never syncs again. if (options?.onDefect !== undefined && Cause.hasDies(cause)) { return Stream.empty; } return Stream.failCause(cause); }), ), ); return subscribeToSession(); }, }), ), ); }), ).pipe( Stream.withSpan("EnvironmentRpc.subscribe", { attributes: { "rpc.method": tag }, }), ); } export function subscribeDynamic( tag: TTag, makeInput: (session: RpcSession) => Effect.Effect>, options?: SubscriptionOptions, ): Stream.Stream< EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure, EnvironmentSupervisor.EnvironmentSupervisor > { return subscribeDynamicMapped(tag, makeInput, (_session, stream) => stream, options); } /** Tags each value before `switchMap` can buffer it across a session change. */ export function subscribeDynamicWithSession( tag: TTag, makeInput: (session: RpcSession) => Effect.Effect>, options?: SubscriptionOptions, ): Stream.Stream< readonly [session: RpcSession, value: EnvironmentRpcStreamValue], EnvironmentRpcStreamFailure, EnvironmentSupervisor.EnvironmentSupervisor > { return subscribeDynamicMapped( tag, makeInput, (session, stream) => stream.pipe(Stream.map((value) => [session, value] as const)), options, ); } export function subscribe( tag: TTag, input: EnvironmentRpcInput, options?: SubscriptionOptions, ): Stream.Stream< EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure, EnvironmentSupervisor.EnvironmentSupervisor > { return subscribeDynamic(tag, () => Effect.succeed(input), options); } export const subscribeVcsBranch = ( input: EnvironmentRpcInput, ) => subscribeDynamicMapped( WS_METHODS.subscribeVcsBranch, () => Effect.succeed(input), (session, stream) => Stream.unwrap( session.initialConfig.pipe( Effect.mapError( (error) => new RpcClientError.RpcClientError({ reason: new RpcClientError.RpcClientDefect({ message: error.message, cause: error, }), }), ), Effect.map((config) => config.environment.capabilities.vcsBranchStatus === true ? stream : session.client[WS_METHODS.subscribeVcsStatus]({ ...input, includeRemote: false, }).pipe( Stream.filter((event) => event._tag !== "remoteUpdated"), Stream.map(({ local }) => ({ refName: local.refName, isRepo: local.isRepo })), ), ), ), ), ); export const refreshVcsBranch = Effect.fn("EnvironmentRpc.refreshVcsBranch")(function* ( input: EnvironmentRpcInput, ) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; const session = yield* currentSession(); const config = yield* session.initialConfig.pipe( Effect.mapError( (error) => new EnvironmentRpcUnavailableError({ environmentId: supervisor.target.environmentId, message: error.message, }), ), ); if (config.environment.capabilities.vcsBranchStatus === true) { return yield* requestInSession(session, WS_METHODS.vcsRefreshBranch, input); } // Older hosts retain their existing status stream without adding per-row wake scans. }); export const request = >( tag: TTag, input: EnvironmentRpcInput, ) => requestGuarded(tag, input); export const runStream = < TTag extends Exclude, >( tag: TTag, input: EnvironmentRpcInput, ) => runStreamGuarded(tag, input);