package localcontrol import ( "context" "errors" "fmt" "iop/apps/agent/internal/clientprocess" iop "iop/proto/gen/iop" ) // ClientProcessController is the daemon-owned process boundary consumed by // local control. It deliberately exposes no executable, environment, or raw // subprocess output. type ClientProcessController interface { Start( context.Context, clientprocess.ClientKind, string, ) (clientprocess.Result, error) Stop( context.Context, clientprocess.ClientKind, string, ) (clientprocess.Result, error) Focus( context.Context, clientprocess.ClientKind, string, ) (clientprocess.Result, error) StartOrFocusFlutter(context.Context, string) (clientprocess.Result, error) } // ClientOperations applies the S11 authorization, request validation, replay, // and durable command-id contract before calling the S15 process owner. type ClientOperations struct { controller ClientProcessController ledger *Ledger } func NewClientOperations( controller ClientProcessController, ledger *Ledger, ) (*ClientOperations, error) { if controller == nil { return nil, fmt.Errorf("localcontrol: client process controller is required") } if ledger == nil { return nil, fmt.Errorf("localcontrol: client command ledger is required") } return &ClientOperations{controller: controller, ledger: ledger}, nil } func (operations *ClientOperations) Handle( ctx context.Context, authorized bool, request *iop.AgentLocalEnvelope, ) *iop.AgentLocalEnvelope { if !authorized { return errorEnvelope( request, protocolError(ErrorPermissionDenied, "local-control peer is not authorized"), ) } if failure := ValidateRequest(request); failure != nil { return errorEnvelope(request, failure) } if !isClientMutation(request.GetOperation()) { return errorEnvelope( request, protocolError( ErrorUnsupportedOperation, "local-control operation is not a client mutation", ), ) } if failure := validateClientOperation(request); failure != nil { return errorEnvelope(request, failure) } replayed, _, failure := operations.requestReplay(ctx, request.GetRequest()) if failure != nil { return errorEnvelope(request, failure) } commandID := request.GetRequest().GetCommandId() requestHash, err := immutableCommandHash( request.GetOperation(), request.GetRequest(), ) if err != nil { return errorEnvelope( request, protocolError(ErrorInternal, "command arguments could not be validated"), ) } accepted := mutationResponse( request, replayed, &iop.AgentLocalMutationResult{ Accepted: true, SubjectId: request.GetRequest().GetClient().GetClientKind(), State: "accepted", Summary: "client command accepted", }, ) for { begin, beginFailure := operations.ledger.BeginCommand( ctx, commandID, requestHash, accepted, ) if beginFailure != nil { return errorEnvelope(request, beginFailure) } switch begin.ownership { case commandCompleted: return begin.response case commandWaiter: if waitFailure := operations.ledger.WaitCommand( ctx, begin.flight, ); waitFailure != nil { return errorEnvelope(request, waitFailure) } continue case commandOwner: defer operations.ledger.ReleaseCommandOwner( commandID, begin.flight, ) default: return errorEnvelope( request, protocolError(ErrorInternal, "command ownership is invalid"), ) } break } result, err := operations.dispatch(ctx, request) if err != nil { return operations.finish( ctx, request, requestHash, errorEnvelope( request, clientOperationError(err), ), nil, ) } wireResult := &iop.AgentLocalMutationResult{ Accepted: true, SubjectId: string(result.Record.Kind), State: string(result.Record.State), Summary: result.Action, } response := mutationResponse(request, replayed, wireResult) event := &iop.AgentLocalEvent{ EventType: request.GetOperation(), SubjectId: wireResult.GetSubjectId(), Mutation: wireResult, } return operations.finish(ctx, request, requestHash, response, event) } func (operations *ClientOperations) requestReplay( ctx context.Context, request *iop.AgentLocalRequest, ) ([]*iop.AgentLocalEvent, LedgerView, *ProtocolError) { if request.ReplayAfterSequence == nil { view, failure := operations.ledger.View(ctx) return nil, view, failure } return operations.ledger.Replay( ctx, request.GetReplayDaemonId(), request.GetReplayAfterSequence(), ) } func (operations *ClientOperations) dispatch( ctx context.Context, request *iop.AgentLocalEnvelope, ) (clientprocess.Result, error) { commandID := request.GetRequest().GetCommandId() kind := clientprocess.ClientKind( request.GetRequest().GetClient().GetClientKind(), ) switch request.GetOperation() { case OperationClientStart: return operations.controller.Start(ctx, kind, commandID) case OperationClientStop: return operations.controller.Stop(ctx, kind, commandID) case OperationClientFocus: return operations.controller.Focus(ctx, kind, commandID) case OperationClientDetail: return operations.controller.StartOrFocusFlutter(ctx, commandID) default: return clientprocess.Result{}, clientprocess.ErrUnknownClient } } func (operations *ClientOperations) finish( ctx context.Context, request *iop.AgentLocalEnvelope, requestHash string, response *iop.AgentLocalEnvelope, event *iop.AgentLocalEvent, ) *iop.AgentLocalEnvelope { final, failure := operations.ledger.FinishCommand( ctx, request.GetRequest().GetCommandId(), requestHash, response, event, ) if failure != nil { return errorEnvelope(request, failure) } return final } func validateClientOperation( request *iop.AgentLocalEnvelope, ) *ProtocolError { arguments := request.GetRequest().GetClient() kind := arguments.GetClientKind() capability := arguments.GetCapability() switch request.GetOperation() { case OperationClientStart, OperationClientStop: if kind != string(clientprocess.ClientFlutter) && kind != string(clientprocess.ClientUnity) { return malformed("client kind must be flutter or unity") } if capability != "" { return malformed("client start and stop do not accept a capability") } case OperationClientFocus: if kind != string(clientprocess.ClientFlutter) || capability != "" { return malformed("client focus is supported only for Flutter") } case OperationClientDetail: if kind != string(clientprocess.ClientUnity) || capability == "" { return malformed( "client detail requires a Unity capability routed through Flutter", ) } } return nil } func clientOperationError(err error) *ProtocolError { switch { case errors.Is(err, clientprocess.ErrUnknownClient), errors.Is(err, clientprocess.ErrClientNotConfigured), errors.Is(err, clientprocess.ErrClientNotRunning), errors.Is(err, clientprocess.ErrFocusUnsupported), errors.Is(err, clientprocess.ErrReceiptCapacityReached): return protocolError(ErrorInvalidState, "client command is not available") case errors.Is(err, clientprocess.ErrCommandActionMismatch): return protocolError( ErrorCommandIDConflict, "command_id was already used with a different client action", ) case errors.Is(err, clientprocess.ErrIdentityAmbiguous), errors.Is(err, clientprocess.ErrStateConflict), errors.Is(err, clientprocess.ErrCommandPending): failure := protocolError( ErrorInvalidState, "client process identity requires reconciliation", ) failure.Retryable = true return failure default: return protocolError(ErrorInternal, "client command could not be applied") } }